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(""); + } else { + expect(result.contentType, `${where} should be an RSC payload`).toContain("text/x-component"); + } +} + +test.describe("concurrent Cache Components requests", () => { + test("overlapping RSC prefetches all complete without poisoning the isolate", async ({ request }) => { + for (let round = 0; round < 3; round++) { + const results = await Promise.all( + PATHS.flatMap((path) => [ + fetchPath(request, path, "document"), + fetchPath(request, path, "route"), + fetchPath(request, path, "segment"), + ]) + ); + + for (const result of results) { + assertComplete(result); + } + } + + // The failure outlives the requests that caused it, so check the isolate still serves traffic. + for (const path of PATHS) { + assertComplete(await fetchPath(request, path, "document")); + } + }); + + test("a navigation refetch overlapping its partial prefetch completes", async ({ request }) => { + // Hover starts a partial prefetch; clicking before it settles fires the dynamic refetch while + // the prefetch request is finishing. The dynamic response must still stream to completion. + for (const path of PATHS) { + const prefetch = fetchPath(request, path, "segment"); + const refetch = fetchPath(request, path, "navigation"); + + assertComplete(await refetch); + assertComplete(await prefetch); + } + + // A hang shows up on later traffic too, so prove the isolate is still healthy. + for (const path of PATHS) { + assertComplete(await fetchPath(request, path, "navigation")); + } + }); +}); diff --git a/examples/e2e/experimental/src/app/tracked-import/[slug]/page.tsx b/examples/e2e/experimental/src/app/tracked-import/[slug]/page.tsx new file mode 100644 index 000000000..ccb623f4e --- /dev/null +++ b/examples/e2e/experimental/src/app/tracked-import/[slug]/page.tsx @@ -0,0 +1,30 @@ +import { headers } from "next/headers"; +import { setTimeout } from "node:timers/promises"; +import { Suspense } from "react"; + +type PageProps = { + params: Promise<{ slug: string }>; +}; + +/** + * A dynamic import inside a Cache Components render makes Next.js track module loading, which is the + * state that used to be shared by every request in a Worker isolate. + */ +async function TrackedImport({ params }: PageProps) { + const [{ slug }] = await Promise.all([params, headers()]); + const { describeSlug } = await import("@/lib/late-module"); + await setTimeout(50); + + return

{describeSlug(slug)}

; +} + +export default function TrackedImportPage({ params }: PageProps) { + return ( +
+

Tracked import shell

+ Loading tracked import...

}> + +
+
+ ); +} diff --git a/examples/e2e/experimental/src/lib/late-module.ts b/examples/e2e/experimental/src/lib/late-module.ts new file mode 100644 index 000000000..3286ee66d --- /dev/null +++ b/examples/e2e/experimental/src/lib/late-module.ts @@ -0,0 +1,3 @@ +export function describeSlug(slug: string): string { + return `Imported module for ${slug}`; +} 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 eda7f8b0f..066291d57 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 @@ -8,11 +8,27 @@ import { computePatchDiff } from "../../utils/test-patch.js"; import { bypassPprCacheInterceptionRule, cacheComponentsSchedulerFileFilter, + moduleLoadingSignalFileFilter, patchCacheComponentsScheduler, patchMiddlewareCacheComponents, + patchModuleLoadingSignal, runInSequentialTasksRule, } from "./cache-components.js"; +/** Shape emitted by `next/dist/server/app-render/module-loading/track-module-loading.instance.js`. */ +const moduleLoadingSignalSource = `const _cachesignal = require("../cache-signal"); +let _moduleLoadingSignal; +function getModuleLoadingSignal() { + if (!_moduleLoadingSignal) { + _moduleLoadingSignal = new _cachesignal.CacheSignal(); + } + return _moduleLoadingSignal; +} +function trackPendingChunkLoad(promise) { + const moduleLoadingSignal = getModuleLoadingSignal(); + moduleLoadingSignal.trackRead(promise); +}`; + const incompatibleSchedulerPattern = /["']_idleStart["']\s*in/; function readSchedulerFixture(name: string): string { @@ -119,6 +135,46 @@ s.push(i(()=>{try{(0,oX.expectNoPendingImmediates)(),r(a)}catch(e){n(e)}}))})}`; expect(cacheComponentsSchedulerFileFilter.test(runtimePath)).toBe(true); }); + test.each([ + "/next/dist/server/app-render/module-loading/track-module-loading.instance.js", + "/next/dist/esm/server/app-render/module-loading/track-module-loading.instance.js", + ])("targets the module loading signal in %s", (modulePath) => { + expect(moduleLoadingSignalFileFilter.test(modulePath)).toBe(true); + }); + + test("does not target the re-exporting module loading facade", () => { + expect( + moduleLoadingSignalFileFilter.test( + "/next/dist/server/app-render/module-loading/track-module-loading.external.js" + ) + ).toBe(false); + }); + + test("scopes the module loading signal to the request that owns its timer handles", () => { + const patched = patchModuleLoadingSignal(moduleLoadingSignalSource, "track-module-loading.instance.js"); + + // Each request gets its own signal, so `beginRead()` never clears another request's handle. + expect(patched).toContain('globalThis[Symbol.for("__cloudflare-context__")]'); + expect(patched).toContain( + "cloudflareRequestScope.__openNextModuleLoadingSignal ??= new _cachesignal.CacheSignal()" + ); + // Imports during isolate startup run outside a request and keep the original instance. + expect(patched).toContain("_moduleLoadingSignal = new _cachesignal.CacheSignal();"); + // Only the getter is rewritten. + expect(patched).toContain("moduleLoadingSignal.trackRead(promise);"); + }); + + test("fails when the module loading signal getter cannot be patched", () => { + const code = `let _moduleLoadingSignal; +function getModuleLoadingSignal() { + return (_moduleLoadingSignal ??= new _cachesignal.CacheSignal()); +}`; + + expect(() => patchModuleLoadingSignal(code, "changed-module-loading.js")).toThrow( + "Failed to scope the module loading signal to a request in changed-module-loading.js" + ); + }); + test("removes the separate atomic timer group emitted by webpack", () => { const unrelatedIdleStartCheck = `function inspectTimer(timer){return "_idleStart" in timer?timer._idleStart:null}`; const code = `function createGroup(){let didRun=false;return function schedule(callback){ 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 751642250..76ad6c318 100644 --- a/packages/cloudflare/src/cli/build/patches/plugins/cache-components.ts +++ b/packages/cloudflare/src/cli/build/patches/plugins/cache-components.ts @@ -8,11 +8,18 @@ import { getCrossPlatformPathRegex } from "@opennextjs/aws/utils/regex.js"; const atomicTimerGroupErrorPattern = /Cannot schedule more timers into a group that already executed/; +const moduleLoadingSignalPattern = /moduleLoadingSignal/; + export const cacheComponentsSchedulerFileFilter = getCrossPlatformPathRegex( String.raw`/(?:next/dist/compiled/next-server/app-page(?:-turbo)?(?:-experimental)?\.runtime\.prod|\.next/server/chunks/.+)\.js$`, { escape: false } ); +export const moduleLoadingSignalFileFilter = getCrossPlatformPathRegex( + String.raw`/next/dist/(?:esm/)?server/app-render/module-loading/track-module-loading\.instance\.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 @@ -33,12 +40,29 @@ export function patchCacheComponents(updater: ContentUpdater): Plugin { }, ]); + updater.updateContent("cache-components-module-loading-signal", [ + { + filter: moduleLoadingSignalFileFilter, + contentFilter: moduleLoadingSignalPattern, + callback: async ({ contents, path: modulePath }) => patchModuleLoadingSignal(contents, modulePath), + }, + ]); + return { name: "patch-cache-components", setup() {}, }; } +export function patchModuleLoadingSignal(contents: string, modulePath: string): string { + const patchedContents = patchCode(contents, requestScopedModuleLoadingSignalRule); + if (patchedContents === contents) { + throw new Error(`Failed to scope the module loading signal to a request in ${modulePath}`); + } + + return patchedContents; +} + export function patchCacheComponentsScheduler(contents: string, runtimePath: string): string { const patchedScheduler = patchCode(contents, runInSequentialTasksRule); if (patchedScheduler === contents) { @@ -146,6 +170,36 @@ fix: |- } `; +/** + * Next.js tracks in-flight dynamic imports and chunk loads on a single module scoped `CacheSignal`. + * That signal stores `pendingTimeoutCleanup`, a closure over a `setImmediate` handle belonging to + * whichever request scheduled it. A Worker isolate serves many requests against that one instance, so + * the next request to import a module runs `beginRead()` and clears a handle owned by another request, + * which workerd rejects with "Cannot perform I/O on behalf of a different request". The throw escapes + * mid render, so that response never completes and the isolate keeps serving truncated bodies. + * + * Key the signal on the per-request store that `runWithCloudflareRequestContext` already establishes, + * so every handle is created and cleared by its owning request. Module loads outside a request (during + * isolate startup) keep using the original module scoped instance. + */ +export const requestScopedModuleLoadingSignalRule = ` +rule: + pattern: + selector: function_declaration + context: "function $FUNCTION() { if (!$SIGNAL) { $SIGNAL = new $CTOR(); } return $SIGNAL; }" +fix: |- + function $FUNCTION() { + const cloudflareRequestScope = globalThis[Symbol.for("__cloudflare-context__")]; + if (!cloudflareRequestScope) { + if (!$SIGNAL) { + $SIGNAL = new $CTOR(); + } + return $SIGNAL; + } + return (cloudflareRequestScope.__openNextModuleLoadingSignal ??= new $CTOR()); + } +`; + /** * 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 From 1aa25259d694f24d8a776581c9fa9dbf65fb55ae Mon Sep 17 00:00:00 2001 From: Nathan Nguyen <146415969+NathanDrake2406@users.noreply.github.com> Date: Sat, 1 Aug 2026 19:57:41 +1000 Subject: [PATCH 05/14] fix(cache): only patch Cache Components internals when the app uses them The scheduler and module loading signal matchers target code that ships in every Next 16.2+ bundle, and they fail the build when they stop matching. Gate them on the resolved Next config so apps without Cache Components cannot be broken by upstream reshaping code they never run. --- .../cloudflare/src/cli/build/bundle-server.ts | 2 +- .../patches/plugins/cache-components.spec.ts | 29 +++++++++++++++- .../build/patches/plugins/cache-components.ts | 34 ++++++++++++++++++- 3 files changed, 62 insertions(+), 3 deletions(-) diff --git a/packages/cloudflare/src/cli/build/bundle-server.ts b/packages/cloudflare/src/cli/build/bundle-server.ts index 3851d7778..191a39abb 100644 --- a/packages/cloudflare/src/cli/build/bundle-server.ts +++ b/packages/cloudflare/src/cli/build/bundle-server.ts @@ -103,7 +103,7 @@ export async function bundleServer(buildOpts: BuildOptions, projectOpts: Project fixRequire(updater), handleOptionalDependencies(optionalDependencies), patchInstrumentation(updater, buildOpts), - patchCacheComponents(updater), + patchCacheComponents(updater, nextConfig), 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 index 066291d57..b5e7dffab 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 @@ -1,18 +1,22 @@ import { readFileSync } from "node:fs"; import type { BuildOptions } from "@opennextjs/aws/build/helper.js"; +import type { ContentUpdater } from "@opennextjs/aws/plugins/content-updater.js"; +import type { NextConfig } from "@opennextjs/aws/types/next-types.js"; import mockFs from "mock-fs"; -import { afterEach, describe, expect, test } from "vitest"; +import { afterEach, describe, expect, test, vi } from "vitest"; import { computePatchDiff } from "../../utils/test-patch.js"; import { bypassPprCacheInterceptionRule, cacheComponentsSchedulerFileFilter, moduleLoadingSignalFileFilter, + patchCacheComponents, patchCacheComponentsScheduler, patchMiddlewareCacheComponents, patchModuleLoadingSignal, runInSequentialTasksRule, + usesCacheComponents, } from "./cache-components.js"; /** Shape emitted by `next/dist/server/app-render/module-loading/track-module-loading.instance.js`. */ @@ -273,4 +277,27 @@ ${unrelatedIdleStartCheck}`; 'route.renderingMode === "PARTIALLY_STATIC"' ); }); + + // The flag moved across Next canaries; missing a spelling would silently skip the patches. + test.each([ + [{ cacheComponents: true }, true], + [{ experimental: { cacheComponents: true } }, true], + [{ experimental: { dynamicIO: true } }, true], + [{ experimental: { ppr: true } }, false], + [{}, false], + ] as const)("detects Cache Components in %j", (nextConfig, expected) => { + expect(usesCacheComponents(nextConfig as NextConfig)).toBe(expected); + }); + + test("registers no patches when the app does not use Cache Components", () => { + const updateContent = vi.fn(); + const updater = { updateContent } as unknown as ContentUpdater; + + patchCacheComponents(updater, {} as NextConfig); + expect(updateContent).not.toHaveBeenCalled(); + + patchCacheComponents(updater, { cacheComponents: true } as NextConfig); + expect(updateContent).toHaveBeenCalledWith("cache-components-scheduler", expect.anything()); + expect(updateContent).toHaveBeenCalledWith("cache-components-module-loading-signal", expect.anything()); + }); }); 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 76ad6c318..59d6c306d 100644 --- a/packages/cloudflare/src/cli/build/patches/plugins/cache-components.ts +++ b/packages/cloudflare/src/cli/build/patches/plugins/cache-components.ts @@ -4,8 +4,29 @@ 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 type { NextConfig } from "@opennextjs/aws/types/next-types.js"; import { getCrossPlatformPathRegex } from "@opennextjs/aws/utils/regex.js"; +type CacheComponentsNextConfig = NextConfig & { + cacheComponents?: boolean; + experimental?: { + cacheComponents?: boolean; + dynamicIO?: boolean; + }; +}; + +/** + * The flag moved from `experimental.dynamicIO` to `experimental.cacheComponents` to a top level + * option over the Next 15/16 canaries, so accept every spelling. + */ +export function usesCacheComponents(nextConfig: CacheComponentsNextConfig): boolean { + return Boolean( + nextConfig.cacheComponents ?? + nextConfig.experimental?.cacheComponents ?? + nextConfig.experimental?.dynamicIO + ); +} + const atomicTimerGroupErrorPattern = /Cannot schedule more timers into a group that already executed/; const moduleLoadingSignalPattern = /moduleLoadingSignal/; @@ -29,8 +50,19 @@ export const moduleLoadingSignalFileFilter = getCrossPlatformPathRegex( * 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. + * + * The matched code ships in every Next 16.2+ bundle, so only register the patches (and their + * fail-loudly errors) when the app actually enables Cache Components. Apps without the flag never + * execute these code paths and must not have their builds fail when Next reshapes the internals. */ -export function patchCacheComponents(updater: ContentUpdater): Plugin { +export function patchCacheComponents(updater: ContentUpdater, nextConfig: NextConfig): Plugin { + if (!usesCacheComponents(nextConfig)) { + return { + name: "patch-cache-components", + setup() {}, + }; + } + updater.updateContent("cache-components-scheduler", [ { filter: cacheComponentsSchedulerFileFilter, From 69807b1bd7acfafc87080656742f64a3e7470d62 Mon Sep 17 00:00:00 2001 From: Nathan Nguyen <146415969+NathanDrake2406@users.noreply.github.com> Date: Mon, 3 Aug 2026 16:03:58 +1000 Subject: [PATCH 06/14] fix(cache): gate the middleware Cache Components patch on the app config Cache interception alone made the generated middleware bundle's shape build-critical for apps that never render Cache Components routes. --- .../patches/plugins/cache-components.spec.ts | 28 +++++++++++++++---- .../build/patches/plugins/cache-components.ts | 8 ++++++ 2 files changed, 30 insertions(+), 6 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 b5e7dffab..697a95e9c 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 @@ -256,28 +256,44 @@ ${unrelatedIdleStartCheck}`; `); }); - test("patches cache interception in the generated external middleware", () => { - mockFs({ - "/output/middleware/handler.mjs": `export async function cacheInterceptor(event) { + const middlewareBundle = `export async function cacheInterceptor(event) { let localizedPath = event.rawPath; const isISR = Object.keys(PrerenderManifest?.routes ?? {}).includes(localizedPath); if (isISR) { return generateResult(event); } return event; -}`, +}`; + + function mockMiddlewareBuild(nextConfig: object, middleware = middlewareBundle) { + mockFs({ + "/app/.next/required-server-files.json": JSON.stringify({ config: nextConfig }), + "/output/middleware/handler.mjs": middleware, }); - patchMiddlewareCacheComponents({ + return { + appBuildOutputPath: "/app", outputDir: "/output", config: { dangerous: { enableCacheInterception: true } }, - } as BuildOptions); + } as BuildOptions; + } + + test("patches cache interception in the generated external middleware", () => { + patchMiddlewareCacheComponents(mockMiddlewareBuild({ cacheComponents: true })); expect(readFileSync("/output/middleware/handler.mjs", "utf8")).toContain( 'route.renderingMode === "PARTIALLY_STATIC"' ); }); + // Cache interception alone must not make the middleware bundle's shape build-critical. + test("leaves the middleware alone when the app does not use Cache Components", () => { + const buildOpts = mockMiddlewareBuild({}, "export function unrelated() {}"); + + expect(() => patchMiddlewareCacheComponents(buildOpts)).not.toThrow(); + expect(readFileSync("/output/middleware/handler.mjs", "utf8")).toBe("export function unrelated() {}"); + }); + // The flag moved across Next canaries; missing a spelling would silently skip the patches. test.each([ [{ cacheComponents: true }, true], 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 59d6c306d..4042863a2 100644 --- a/packages/cloudflare/src/cli/build/patches/plugins/cache-components.ts +++ b/packages/cloudflare/src/cli/build/patches/plugins/cache-components.ts @@ -1,6 +1,7 @@ import { existsSync, readFileSync, writeFileSync } from "node:fs"; import path from "node:path"; +import { loadConfig } from "@opennextjs/aws/adapters/config/util.js"; 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"; @@ -112,12 +113,19 @@ export function patchCacheComponentsScheduler(contents: string, runtimePath: str /** * 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. + * + * Only apps combining Cache Components with cache interception hit the unresumable shell, so apps + * without the flag must not depend on the shape of the generated middleware. */ export function patchMiddlewareCacheComponents(buildOpts: BuildOptions): void { if (buildOpts.config.dangerous?.enableCacheInterception !== true) { return; } + if (!usesCacheComponents(loadConfig(path.join(buildOpts.appBuildOutputPath, ".next")))) { + 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"); From b150d1ba803ea4f829b7cfd83eb981eec9db95d6 Mon Sep 17 00:00:00 2001 From: Nathan Nguyen <146415969+NathanDrake2406@users.noreply.github.com> Date: Mon, 3 Aug 2026 16:38:28 +1000 Subject: [PATCH 07/14] fix(cache): keep the module loading signal shared across requests Next.js documents that an `import()` promise is commonly cached in user land, so only the first render executes the instrumented import and every later render learns about the pending promise from the shared signal. Per-request signals let a second render's `cacheReady()` resolve while a module it depends on was still loading. Keep the signal shared and drop only the request bound timer: the shared signal has no listeners to notify because `trackPendingModules` subscribes render signals instead, so the scheduled callback only ever walked empty arrays. Scheduling still happens when a listener exists, so a future Next.js that awaits this signal fails loudly rather than hanging. Also assert at build end that each patch actually matched, narrow the cache interception docs back to Cache Components, and check RSC responses against route content instead of a non-empty body. --- .changeset/bright-caches-stream.md | 2 +- .../experimental/e2e/concurrent-rsc.test.ts | 92 +++++---- examples/e2e/experimental/e2e/ppr.test.ts | 6 +- packages/cloudflare/src/api/config.ts | 4 +- .../patches/plugins/cache-components.spec.ts | 181 +++++++++++++++--- .../build/patches/plugins/cache-components.ts | 72 +++++-- 6 files changed, 279 insertions(+), 78 deletions(-) diff --git a/.changeset/bright-caches-stream.md b/.changeset/bright-caches-stream.md index ad6fb319c..7879da050 100644 --- a/.changeset/bright-caches-stream.md +++ b/.changeset/bright-caches-stream.md @@ -6,4 +6,4 @@ 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. +Also stop the shared module loading `CacheSignal` from holding request bound timer handles. Next.js keeps one signal per process so that a dynamic import cached in user land is still awaited by every render, but on Workers the signal's immediate/timeout cleanup handle belongs to the request that scheduled it, so an overlapping request clearing it fails with "Cannot perform I/O on behalf of a different request" and the response streams stay incomplete. The signal stays shared and only skips scheduling that timer, which on this code path has no listeners to notify. diff --git a/examples/e2e/experimental/e2e/concurrent-rsc.test.ts b/examples/e2e/experimental/e2e/concurrent-rsc.test.ts index a8d8132e5..80bb64469 100644 --- a/examples/e2e/experimental/e2e/concurrent-rsc.test.ts +++ b/examples/e2e/experimental/e2e/concurrent-rsc.test.ts @@ -14,21 +14,38 @@ const SEGMENT_PREFETCH = { ...ROUTE_PREFETCH, "next-router-segment-prefetch": "/ // 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", -]; +/** + * Every response is checked against content the route must contain, because a poisoned isolate answers + * 200 with a body that is empty or cut short. `shell` is prerendered, so every variant carries it; + * `resolved` is flushed last, so only a fully rendered response can contain it — that is what proves a + * stream was not truncated. Prefetches deliberately stop at the shell and are not checked for it. + * + * 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 ROUTES = [ + { path: "/ppr", shell: "static component that does not change", resolved: "This component should be SSR" }, + { path: "/ppr/first", shell: "Static shell", resolved: "Dynamic slug: first" }, + { path: "/ppr/second", shell: "Static shell", resolved: "Dynamic slug: second" }, + { path: "/use-cache/ssr", shell: "Cache", resolved: "fully-cached" }, + { path: "/use-cache/isr", shell: "Cache", resolved: "fully-cached" }, + { + path: "/tracked-import/first", + shell: "Tracked import shell", + resolved: "Imported module for first", + }, + { + path: "/tracked-import/second", + shell: "Tracked import shell", + resolved: "Imported module for second", + }, +] as const; + +type Route = (typeof ROUTES)[number]; type Fetched = { - path: string; - kind: string; + route: Route; + kind: keyof typeof VARIANTS; status: number; contentType: string; body: Buffer; @@ -36,12 +53,12 @@ type Fetched = { async function fetchPath( request: APIRequestContext, - path: string, + route: Route, kind: keyof typeof VARIANTS ): Promise { - const response = await request.get(path, { headers: VARIANTS[kind] }); + const response = await request.get(route.path, { headers: VARIANTS[kind] }); return { - path, + route, kind, status: response.status(), contentType: response.headers()["content-type"] ?? "", @@ -57,17 +74,28 @@ const VARIANTS = { }; function assertComplete(result: Fetched) { - const where = `${result.kind} ${result.path}`; + const where = `${result.kind} ${result.route.path}`; + const isPrefetch = result.kind === "route" || result.kind === "segment"; // 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); + + const body = result.body.toString("utf8"); + expect(body, `${where} should contain its prerendered shell`).toContain(result.route.shell); 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(""); - } else { - expect(result.contentType, `${where} should be an RSC payload`).toContain("text/x-component"); + expect(body, `${where} should not be truncated`).toContain(""); + expect(body, `${where} should have resolved its dynamic hole`).toContain(result.route.resolved); + return; + } + + expect(result.contentType, `${where} should be an RSC payload`).toContain("text/x-component"); + + // A prefetch stops at the shell by design; a dynamic RSC response has to carry the resolved model, + // which is the part a truncated Flight stream loses. + if (!isPrefetch) { + expect(body, `${where} should have resolved its dynamic hole`).toContain(result.route.resolved); } } @@ -75,10 +103,10 @@ test.describe("concurrent Cache Components requests", () => { test("overlapping RSC prefetches all complete without poisoning the isolate", async ({ request }) => { for (let round = 0; round < 3; round++) { const results = await Promise.all( - PATHS.flatMap((path) => [ - fetchPath(request, path, "document"), - fetchPath(request, path, "route"), - fetchPath(request, path, "segment"), + ROUTES.flatMap((route) => [ + fetchPath(request, route, "document"), + fetchPath(request, route, "route"), + fetchPath(request, route, "segment"), ]) ); @@ -88,25 +116,25 @@ test.describe("concurrent Cache Components requests", () => { } // The failure outlives the requests that caused it, so check the isolate still serves traffic. - for (const path of PATHS) { - assertComplete(await fetchPath(request, path, "document")); + for (const route of ROUTES) { + assertComplete(await fetchPath(request, route, "document")); } }); test("a navigation refetch overlapping its partial prefetch completes", async ({ request }) => { // Hover starts a partial prefetch; clicking before it settles fires the dynamic refetch while // the prefetch request is finishing. The dynamic response must still stream to completion. - for (const path of PATHS) { - const prefetch = fetchPath(request, path, "segment"); - const refetch = fetchPath(request, path, "navigation"); + for (const route of ROUTES) { + const prefetch = fetchPath(request, route, "segment"); + const refetch = fetchPath(request, route, "navigation"); assertComplete(await refetch); assertComplete(await prefetch); } // A hang shows up on later traffic too, so prove the isolate is still healthy. - for (const path of PATHS) { - assertComplete(await fetchPath(request, path, "navigation")); + for (const route of ROUTES) { + assertComplete(await fetchPath(request, route, "navigation")); } }); }); diff --git a/examples/e2e/experimental/e2e/ppr.test.ts b/examples/e2e/experimental/e2e/ppr.test.ts index 78697b196..2f1bf32bf 100644 --- a/examples/e2e/experimental/e2e/ppr.test.ts +++ b/examples/e2e/experimental/e2e/ppr.test.ts @@ -49,14 +49,16 @@ test.describe("PPR", () => { }); test("dynamic PPR supports route and segment prefetch requests", async ({ request }) => { - for (const headers of [ + const variants: Record[] = [ { rsc: "1", "next-router-prefetch": "1" }, { rsc: "1", "next-router-prefetch": "1", "next-router-segment-prefetch": "/_tree", }, - ]) { + ]; + + for (const headers of variants) { const response = await request.get("/ppr/first", { headers }); expect(response.status()).toEqual(200); diff --git a/packages/cloudflare/src/api/config.ts b/packages/cloudflare/src/api/config.ts index 330832eae..302c1621e 100644 --- a/packages/cloudflare/src/api/config.ts +++ b/packages/cloudflare/src/api/config.ts @@ -44,7 +44,9 @@ export type CloudflareOverrides = { /** * Enable cache interception - * Partially prerendered routes bypass interception so Next.js can resume their postponed work. + * Cache Components routes bypass interception so Next.js can resume their postponed work. + * Should still be `false` with `experimental.ppr` alone: the interceptor only holds the cached + * shell, so it would serve a partial page as if it were complete. * @default false */ enableCacheInterception?: boolean; 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 697a95e9c..f9beb4f73 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 @@ -1,4 +1,5 @@ import { readFileSync } from "node:fs"; +import { createRequire } from "node:module"; import type { BuildOptions } from "@opennextjs/aws/build/helper.js"; import type { ContentUpdater } from "@opennextjs/aws/plugins/content-updater.js"; @@ -19,21 +20,76 @@ import { usesCacheComponents, } from "./cache-components.js"; -/** Shape emitted by `next/dist/server/app-render/module-loading/track-module-loading.instance.js`. */ -const moduleLoadingSignalSource = `const _cachesignal = require("../cache-signal"); -let _moduleLoadingSignal; -function getModuleLoadingSignal() { - if (!_moduleLoadingSignal) { - _moduleLoadingSignal = new _cachesignal.CacheSignal(); - } - return _moduleLoadingSignal; +const incompatibleSchedulerPattern = /["']_idleStart["']\s*in/; + +/** + * The module loading patch is only meaningful against the real Next.js implementation: what it has to + * preserve is how `CacheSignal.subscribeToReads` replays in-flight reads to a late subscriber. Resolve + * both from the example app, which pins the Next.js version this adapter is built against. + */ +const nextRequire = createRequire( + new URL("../../../../../../../examples/e2e/experimental/package.json", import.meta.url) +); +const moduleTrackerPath = nextRequire.resolve( + "next/dist/server/app-render/module-loading/track-module-loading.instance.js" +); +const trackerRequire = createRequire(moduleTrackerPath); +const { CacheSignal } = trackerRequire("../cache-signal") as { + CacheSignal: new () => { + hasPendingReads(): boolean; + cacheReady(): Promise; + }; +}; + +type ModuleTracker = { + trackPendingImport(exportsOrPromise: unknown): void; + trackPendingModules(cacheSignal: unknown): void; +}; + +/** Runs the patched copy of Next's real module tracker so its behaviour, not its text, is asserted. */ +function loadPatchedModuleTracker(): ModuleTracker { + const patched = patchModuleLoadingSignal(readFileSync(moduleTrackerPath, "utf8"), moduleTrackerPath); + const module = { exports: {} as ModuleTracker }; + + new Function("require", "module", "exports", patched)(trackerRequire, module, module.exports); + + return module.exports; } -function trackPendingChunkLoad(promise) { - const moduleLoadingSignal = getModuleLoadingSignal(); - moduleLoadingSignal.trackRead(promise); -}`; -const incompatibleSchedulerPattern = /["']_idleStart["']\s*in/; +/** Long enough for the signal's `nextTick` -> `setImmediate` -> `setTimeout` chain to settle. */ +const tick = () => new Promise((resolve) => setTimeout(resolve, 10)); + +const cloudflareContextSymbol = Symbol.for("__cloudflare-context__"); + +/** + * `runWithCloudflareRequestContext` exposes the current request's store through this symbol, so a + * patch that keys anything on it observes a different object per request. Mimic that here to prove the + * module tracker does not. + */ +function withRequestScopes(run: (enterRequest: (name: string) => void) => T): T { + let current: Record | undefined; + const descriptor = Object.getOwnPropertyDescriptor(globalThis, cloudflareContextSymbol); + Object.defineProperty(globalThis, cloudflareContextSymbol, { + configurable: true, + get: () => current, + }); + + const scopes = new Map>(); + try { + return run((name) => { + if (!scopes.has(name)) { + scopes.set(name, { env: {}, ctx: {}, cf: {} }); + } + current = scopes.get(name); + }); + } finally { + if (descriptor) { + Object.defineProperty(globalThis, cloudflareContextSymbol, descriptor); + } else { + delete (globalThis as Record)[cloudflareContextSymbol]; + } + } +} function readSchedulerFixture(name: string): string { return readFileSync(new URL(`./fixtures/cache-components/${name}`, import.meta.url), "utf8"); @@ -154,18 +210,80 @@ s.push(i(()=>{try{(0,oX.expectNoPendingImmediates)(),r(a)}catch(e){n(e)}}))})}`; ).toBe(false); }); - test("scopes the module loading signal to the request that owns its timer handles", () => { - const patched = patchModuleLoadingSignal(moduleLoadingSignalSource, "track-module-loading.instance.js"); + test("keeps a userspace cached import visible to a request that never executed it", async () => { + const { trackPendingImport, trackPendingModules } = loadPatchedModuleTracker(); + + const { renderB, settle } = withRequestScopes((enterRequest) => { + // The pattern Next.js documents on `trackDynamicImport`: only the first caller runs the + // instrumented `import()`, every later caller gets the already created promise back. + let cached: Promise | undefined; + let settle: () => void = () => {}; + function loadOnce() { + if (!cached) { + cached = new Promise((resolve) => (settle = resolve)); + trackPendingImport(cached); + } + return cached; + } + + enterRequest("A"); + const renderA = new CacheSignal(); + trackPendingModules(renderA); + loadOnce(); + + // A second request starts while the import is in flight and reuses the cached promise, so + // nothing tracks the import on its behalf — it has to learn about it from the shared signal. + enterRequest("B"); + const renderB = new CacheSignal(); + trackPendingModules(renderB); + loadOnce(); + + return { renderB, settle }; + }); - // Each request gets its own signal, so `beginRead()` never clears another request's handle. - expect(patched).toContain('globalThis[Symbol.for("__cloudflare-context__")]'); - expect(patched).toContain( - "cloudflareRequestScope.__openNextModuleLoadingSignal ??= new _cachesignal.CacheSignal()" - ); - // Imports during isolate startup run outside a request and keep the original instance. - expect(patched).toContain("_moduleLoadingSignal = new _cachesignal.CacheSignal();"); - // Only the getter is rewritten. - expect(patched).toContain("moduleLoadingSignal.trackRead(promise);"); + expect(renderB.hasPendingReads()).toBe(true); + + let ready = false; + void renderB.cacheReady().then(() => (ready = true)); + await tick(); + expect(ready, "cacheReady must not resolve while the import is pending").toBe(false); + + settle(); + await tick(); + expect(ready).toBe(true); + }); + + test("does not clear a timer handle owned by another request", async () => { + const { trackPendingImport } = loadPatchedModuleTracker(); + + // workerd rejects clearing a handle created by a different request. The shared signal is the one + // object every request touches, so a handle stored on it is the one that gets cleared foreign. + const realClearImmediate = globalThis.clearImmediate; + const realClearTimeout = globalThis.clearTimeout; + let cleanupAttempts = 0; + const rejectForeignCleanup = () => { + cleanupAttempts++; + throw new Error("Cannot perform I/O on behalf of a different request."); + }; + + try { + globalThis.clearImmediate = rejectForeignCleanup as typeof clearImmediate; + globalThis.clearTimeout = rejectForeignCleanup as typeof clearTimeout; + + // One request's import settles, which is what schedules the cleanup handle... + trackPendingImport(Promise.resolve()); + await Promise.resolve(); + await Promise.resolve(); + + // ...and the next request's import begins before that handle fires, which is where upstream + // clears a handle it may not own. + expect(() => trackPendingImport(Promise.resolve())).not.toThrow(); + } finally { + globalThis.clearImmediate = realClearImmediate; + globalThis.clearTimeout = realClearTimeout; + } + + expect(cleanupAttempts, "the shared signal must not hold request bound timer handles").toBe(0); }); test("fails when the module loading signal getter cannot be patched", () => { @@ -175,7 +293,7 @@ function getModuleLoadingSignal() { }`; expect(() => patchModuleLoadingSignal(code, "changed-module-loading.js")).toThrow( - "Failed to scope the module loading signal to a request in changed-module-loading.js" + "Failed to patch the module loading signal in changed-module-loading.js" ); }); @@ -316,4 +434,17 @@ ${unrelatedIdleStartCheck}`; expect(updateContent).toHaveBeenCalledWith("cache-components-scheduler", expect.anything()); expect(updateContent).toHaveBeenCalledWith("cache-components-module-loading-signal", expect.anything()); }); + + // A filter that stops matching skips the callback silently, which would ship an unpatched build. + test("fails the build when a patch matched nothing", () => { + const updater = { updateContent: vi.fn() } as unknown as ContentUpdater; + const plugin = patchCacheComponents(updater, { cacheComponents: true } as NextConfig); + + let onEnd: (result: { errors: unknown[] }) => void = () => {}; + plugin.setup({ onEnd: (callback: typeof onEnd) => (onEnd = callback) } as never); + + expect(() => onEnd({ errors: [] })).toThrow(/scheduler and module loading signal patches/); + // A build that already failed keeps its own error. + expect(() => onEnd({ errors: [{ text: "something else broke" }] })).not.toThrow(); + }); }); 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 4042863a2..72e7a5cdb 100644 --- a/packages/cloudflare/src/cli/build/patches/plugins/cache-components.ts +++ b/packages/cloudflare/src/cli/build/patches/plugins/cache-components.ts @@ -64,12 +64,18 @@ export function patchCacheComponents(updater: ContentUpdater, nextConfig: NextCo }; } + // `ContentUpdater` skips a callback when the file filter or the content filter stops matching, so a + // renamed error message or a moved file would ship an unpatched build with no error at all. + const applied = { scheduler: false, "module loading signal": false }; + updater.updateContent("cache-components-scheduler", [ { filter: cacheComponentsSchedulerFileFilter, contentFilter: atomicTimerGroupErrorPattern, - callback: async ({ contents, path: runtimePath }) => - patchCacheComponentsScheduler(contents, runtimePath), + callback: async ({ contents, path: runtimePath }) => { + applied.scheduler = true; + return patchCacheComponentsScheduler(contents, runtimePath); + }, }, ]); @@ -77,20 +83,42 @@ export function patchCacheComponents(updater: ContentUpdater, nextConfig: NextCo { filter: moduleLoadingSignalFileFilter, contentFilter: moduleLoadingSignalPattern, - callback: async ({ contents, path: modulePath }) => patchModuleLoadingSignal(contents, modulePath), + callback: async ({ contents, path: modulePath }) => { + applied["module loading signal"] = true; + return patchModuleLoadingSignal(contents, modulePath); + }, }, ]); return { name: "patch-cache-components", - setup() {}, + setup(build) { + build.onEnd((result) => { + // Another plugin already failed the build, so do not bury its error under ours. + if (result.errors.length > 0) { + return; + } + + const missing = Object.entries(applied) + .filter(([, wasApplied]) => !wasApplied) + .map(([name]) => name); + + if (missing.length > 0) { + throw new Error( + `Cache Components is enabled but the Next.js ${missing.join(" and ")} patch${ + missing.length > 1 ? "es" : "" + } matched nothing. Next.js likely moved or reshaped the code these patches target, and the app would render incorrectly on Workers. Please report this against @opennextjs/cloudflare with your Next.js version.` + ); + } + }); + }, }; } export function patchModuleLoadingSignal(contents: string, modulePath: string): string { - const patchedContents = patchCode(contents, requestScopedModuleLoadingSignalRule); + const patchedContents = patchCode(contents, sharedModuleLoadingSignalRule); if (patchedContents === contents) { - throw new Error(`Failed to scope the module loading signal to a request in ${modulePath}`); + throw new Error(`Failed to patch the module loading signal in ${modulePath}`); } return patchedContents; @@ -218,25 +246,35 @@ fix: |- * which workerd rejects with "Cannot perform I/O on behalf of a different request". The throw escapes * mid render, so that response never completes and the isolate keeps serving truncated bodies. * - * Key the signal on the per-request store that `runWithCloudflareRequestContext` already establishes, - * so every handle is created and cleared by its owning request. Module loads outside a request (during - * isolate startup) keep using the original module scoped instance. + * The signal has to stay shared: an `import()` promise is commonly cached in user land, so only the + * first render executes the instrumented import and every later render has to learn about that pending + * promise from the shared signal (`subscribeToReads` replays in-flight reads to each new subscriber). + * Per-request signals would let a second render's `cacheReady()` resolve while a module it depends on + * is still loading. + * + * Only the timer is request bound, so drop it: `trackPendingModules` subscribes render signals instead + * of registering listeners, so the shared signal has no listeners to notify and the scheduled callback + * only ever walks empty arrays. Keep scheduling when a listener does exist, so a future Next.js that + * awaits this signal directly fails loudly instead of silently never resolving. */ -export const requestScopedModuleLoadingSignalRule = ` +export const sharedModuleLoadingSignalRule = ` rule: pattern: selector: function_declaration context: "function $FUNCTION() { if (!$SIGNAL) { $SIGNAL = new $CTOR(); } return $SIGNAL; }" fix: |- function $FUNCTION() { - const cloudflareRequestScope = globalThis[Symbol.for("__cloudflare-context__")]; - if (!cloudflareRequestScope) { - if (!$SIGNAL) { - $SIGNAL = new $CTOR(); - } - return $SIGNAL; + if (!$SIGNAL) { + $SIGNAL = new $CTOR(); + const sharedSignal = $SIGNAL; + const scheduleListenerNotification = sharedSignal.noMorePendingCaches.bind(sharedSignal); + sharedSignal.noMorePendingCaches = function () { + if (sharedSignal.listeners.length > 0 || sharedSignal.earlyListeners.length > 0) { + scheduleListenerNotification(); + } + }; } - return (cloudflareRequestScope.__openNextModuleLoadingSignal ??= new $CTOR()); + return $SIGNAL; } `; From 21ebb987c671b8a864d38a9f68df1d5fb4433f51 Mon Sep 17 00:00:00 2001 From: Nathan Nguyen <146415969+NathanDrake2406@users.noreply.github.com> Date: Wed, 19 Aug 2026 15:42:34 +1000 Subject: [PATCH 08/14] fix(cache-components): isolate module loading signals by request Overlapping renders share Next.js module-loading CacheSignal state. Its cleanup closures retain request-owned timer handles, so later requests can fail with cross-request I/O and return truncated RSC streams. Keep only unresolved import promises in module scope. Seed a separate signal for each request, which preserves cached-import waiting without sharing timer handles. Strengthen concurrent response checks for incomplete HTML and Flight payloads. --- .changeset/bright-caches-stream.md | 2 +- .../experimental/e2e/concurrent-rsc.test.ts | 74 +++++++++++---- .../patches/plugins/cache-components.spec.ts | 95 +++++++++++-------- .../build/patches/plugins/cache-components.ts | 73 ++++++++------ 4 files changed, 161 insertions(+), 83 deletions(-) diff --git a/.changeset/bright-caches-stream.md b/.changeset/bright-caches-stream.md index 7879da050..1e842341c 100644 --- a/.changeset/bright-caches-stream.md +++ b/.changeset/bright-caches-stream.md @@ -6,4 +6,4 @@ 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 stop the shared module loading `CacheSignal` from holding request bound timer handles. Next.js keeps one signal per process so that a dynamic import cached in user land is still awaited by every render, but on Workers the signal's immediate/timeout cleanup handle belongs to the request that scheduled it, so an overlapping request clearing it fails with "Cannot perform I/O on behalf of a different request" and the response streams stay incomplete. The signal stays shared and only skips scheduling that timer, which on this code path has no listeners to notify. +Also keep module loading `CacheSignal` instances and subscriptions request scoped. A shared set retains only pending import promises, so later requests still wait for user-land cached imports without clearing timer handles that belong to another request. diff --git a/examples/e2e/experimental/e2e/concurrent-rsc.test.ts b/examples/e2e/experimental/e2e/concurrent-rsc.test.ts index 80bb64469..630f3da64 100644 --- a/examples/e2e/experimental/e2e/concurrent-rsc.test.ts +++ b/examples/e2e/experimental/e2e/concurrent-rsc.test.ts @@ -15,29 +15,56 @@ const SEGMENT_PREFETCH = { ...ROUTE_PREFETCH, "next-router-segment-prefetch": "/ const NAVIGATION = { rsc: "1", "next-url": "/" }; /** - * Every response is checked against content the route must contain, because a poisoned isolate answers - * 200 with a body that is empty or cut short. `shell` is prerendered, so every variant carries it; - * `resolved` is flushed last, so only a fully rendered response can contain it — that is what proves a - * stream was not truncated. Prefetches deliberately stop at the shell and are not checked for it. + * Every response is checked against content it must contain, because a poisoned isolate answers 200 + * with a body that is empty or cut short. Documents carry the prerendered `shell`; `resolvedHtml` and + * `resolvedRsc` are flushed last, so only a fully rendered response can contain them. Prefetch depth + * varies by route, but every valid prefetch contains a root model instead of only a close marker. * * 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 ROUTES = [ - { path: "/ppr", shell: "static component that does not change", resolved: "This component should be SSR" }, - { path: "/ppr/first", shell: "Static shell", resolved: "Dynamic slug: first" }, - { path: "/ppr/second", shell: "Static shell", resolved: "Dynamic slug: second" }, - { path: "/use-cache/ssr", shell: "Cache", resolved: "fully-cached" }, - { path: "/use-cache/isr", shell: "Cache", resolved: "fully-cached" }, + { + path: "/ppr", + shell: "static component that does not change", + resolvedHtml: "This component should be SSR", + resolvedRsc: "This component should be SSR", + }, + { + path: "/ppr/first", + shell: "Static shell", + resolvedHtml: "Dynamic slug: first", + resolvedRsc: '"data-testid":"dynamic-slug"', + }, + { + path: "/ppr/second", + shell: "Static shell", + resolvedHtml: "Dynamic slug: second", + resolvedRsc: '"data-testid":"dynamic-slug"', + }, + { + path: "/use-cache/ssr", + shell: "Cache", + resolvedHtml: 'data-testid="fully-cached"', + resolvedRsc: '"data-testid":"fully-cached"', + }, + { + path: "/use-cache/isr", + shell: "Cache", + resolvedHtml: 'data-testid="fully-cached"', + resolvedRsc: '"data-testid":"fully-cached"', + }, { path: "/tracked-import/first", shell: "Tracked import shell", - resolved: "Imported module for first", + resolvedHtml: "Imported module for first", + resolvedRsc: "Imported module for first", }, { path: "/tracked-import/second", shell: "Tracked import shell", - resolved: "Imported module for second", + resolvedHtml: "Imported module for second", + resolvedRsc: "Imported module for second", }, ] as const; @@ -81,22 +108,35 @@ function assertComplete(result: Fetched) { expect(result.status, `${where} should complete`).toEqual(200); const body = result.body.toString("utf8"); + if (isPrefetch) { + // Prefetch depth varies by route: some include the static shell and some only router metadata. A + // usable response always has a root model; the reported failure had only a one-byte close marker. + expect(result.contentType, `${where} should be an RSC payload`).toContain("text/x-component"); + expect(body, `${where} should contain a root model`).toContain("0:{"); + expect(body, `${where} should not contain a Flight error record`).not.toMatch(/^\w+:E\{/m); + + if (result.kind === "segment") { + expect(body, `${where} should contain its router tree`).toContain('"tree"'); + expect(body, `${where} should identify its build`).toContain('"buildId"'); + } + return; + } + expect(body, `${where} should contain its prerendered shell`).toContain(result.route.shell); if (result.contentType.includes("text/html")) { // Truncated streams lose the closing tag that Next.js flushes last. expect(body, `${where} should not be truncated`).toContain(""); - expect(body, `${where} should have resolved its dynamic hole`).toContain(result.route.resolved); + expect(body.replaceAll("", ""), `${where} should have resolved its dynamic hole`).toContain( + result.route.resolvedHtml + ); return; } expect(result.contentType, `${where} should be an RSC payload`).toContain("text/x-component"); - // A prefetch stops at the shell by design; a dynamic RSC response has to carry the resolved model, - // which is the part a truncated Flight stream loses. - if (!isPrefetch) { - expect(body, `${where} should have resolved its dynamic hole`).toContain(result.route.resolved); - } + // A dynamic RSC response has to carry the resolved model, which a truncated Flight stream loses. + expect(body, `${where} should have resolved its dynamic hole`).toContain(result.route.resolvedRsc); } test.describe("concurrent Cache Components requests", () => { 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 f9beb4f73..3b1c3ed94 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 @@ -62,11 +62,12 @@ const tick = () => new Promise((resolve) => setTimeout(resolve, 10)); const cloudflareContextSymbol = Symbol.for("__cloudflare-context__"); /** - * `runWithCloudflareRequestContext` exposes the current request's store through this symbol, so a - * patch that keys anything on it observes a different object per request. Mimic that here to prove the - * module tracker does not. + * `runWithCloudflareRequestContext` exposes the current request's store through this symbol. Keep the + * getter installed across awaits so tests can model two requests sharing one module instance. */ -function withRequestScopes(run: (enterRequest: (name: string) => void) => T): T { +async function withRequestScopes( + run: (enterRequest: (name: string) => void) => T | Promise +): Promise { let current: Record | undefined; const descriptor = Object.getOwnPropertyDescriptor(globalThis, cloudflareContextSymbol); Object.defineProperty(globalThis, cloudflareContextSymbol, { @@ -76,7 +77,7 @@ function withRequestScopes(run: (enterRequest: (name: string) => void) => T): const scopes = new Map>(); try { - return run((name) => { + return await run((name) => { if (!scopes.has(name)) { scopes.set(name, { env: {}, ctx: {}, cf: {} }); } @@ -213,7 +214,7 @@ s.push(i(()=>{try{(0,oX.expectNoPendingImmediates)(),r(a)}catch(e){n(e)}}))})}`; test("keeps a userspace cached import visible to a request that never executed it", async () => { const { trackPendingImport, trackPendingModules } = loadPatchedModuleTracker(); - const { renderB, settle } = withRequestScopes((enterRequest) => { + await withRequestScopes(async (enterRequest) => { // The pattern Next.js documents on `trackDynamicImport`: only the first caller runs the // instrumented `import()`, every later caller gets the already created promise back. let cached: Promise | undefined; @@ -238,58 +239,78 @@ s.push(i(()=>{try{(0,oX.expectNoPendingImmediates)(),r(a)}catch(e){n(e)}}))})}`; trackPendingModules(renderB); loadOnce(); - return { renderB, settle }; - }); - - expect(renderB.hasPendingReads()).toBe(true); + expect(renderB.hasPendingReads()).toBe(true); - let ready = false; - void renderB.cacheReady().then(() => (ready = true)); - await tick(); - expect(ready, "cacheReady must not resolve while the import is pending").toBe(false); + let ready = false; + void renderB.cacheReady().then(() => (ready = true)); + await tick(); + expect(ready, "cacheReady must not resolve while the import is pending").toBe(false); - settle(); - await tick(); - expect(ready).toBe(true); + settle(); + await tick(); + expect(ready).toBe(true); + }); }); test("does not clear a timer handle owned by another request", async () => { const { trackPendingImport } = loadPatchedModuleTracker(); - // workerd rejects clearing a handle created by a different request. The shared signal is the one - // object every request touches, so a handle stored on it is the one that gets cleared foreign. + // Model workerd's ownership check on immediate handles. The first resolved import leaves a cleanup + // handle pending; starting an import in another request must not touch it. + const realSetImmediate = globalThis.setImmediate; const realClearImmediate = globalThis.clearImmediate; - const realClearTimeout = globalThis.clearTimeout; + const scheduled = new Set(); + const owners = new Map(); + let currentRequest = ""; let cleanupAttempts = 0; - const rejectForeignCleanup = () => { - cleanupAttempts++; - throw new Error("Cannot perform I/O on behalf of a different request."); - }; try { - globalThis.clearImmediate = rejectForeignCleanup as typeof clearImmediate; - globalThis.clearTimeout = rejectForeignCleanup as typeof clearTimeout; - - // One request's import settles, which is what schedules the cleanup handle... - trackPendingImport(Promise.resolve()); - await Promise.resolve(); - await Promise.resolve(); - - // ...and the next request's import begins before that handle fires, which is where upstream - // clears a handle it may not own. - expect(() => trackPendingImport(Promise.resolve())).not.toThrow(); + globalThis.setImmediate = ((callback: (...args: unknown[]) => void) => { + const handle = realSetImmediate(callback); + scheduled.add(handle); + owners.set(handle, currentRequest); + return handle; + }) as typeof setImmediate; + globalThis.clearImmediate = ((handle: NodeJS.Immediate) => { + if (owners.get(handle) !== currentRequest) { + cleanupAttempts++; + throw new Error("Cannot perform I/O on behalf of a different request."); + } + scheduled.delete(handle); + owners.delete(handle); + return realClearImmediate(handle); + }) as typeof clearImmediate; + + await withRequestScopes(async (enterRequest) => { + currentRequest = "A"; + enterRequest(currentRequest); + trackPendingImport(Promise.resolve()); + await Promise.resolve(); + await Promise.resolve(); + + currentRequest = "B"; + enterRequest(currentRequest); + expect(() => trackPendingImport(Promise.resolve())).not.toThrow(); + }); } finally { + for (const handle of scheduled) { + realClearImmediate(handle); + } + globalThis.setImmediate = realSetImmediate; globalThis.clearImmediate = realClearImmediate; - globalThis.clearTimeout = realClearTimeout; } - expect(cleanupAttempts, "the shared signal must not hold request bound timer handles").toBe(0); + expect(cleanupAttempts, "one request must not clear another request's timer").toBe(0); }); test("fails when the module loading signal getter cannot be patched", () => { const code = `let _moduleLoadingSignal; function getModuleLoadingSignal() { return (_moduleLoadingSignal ??= new _cachesignal.CacheSignal()); +} +function trackPendingChunkLoad(promise) { + const moduleLoadingSignal = getModuleLoadingSignal(); + moduleLoadingSignal.trackRead(promise); }`; expect(() => patchModuleLoadingSignal(code, "changed-module-loading.js")).toThrow( 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 72e7a5cdb..29e394ce6 100644 --- a/packages/cloudflare/src/cli/build/patches/plugins/cache-components.ts +++ b/packages/cloudflare/src/cli/build/patches/plugins/cache-components.ts @@ -116,8 +116,18 @@ export function patchCacheComponents(updater: ContentUpdater, nextConfig: NextCo } export function patchModuleLoadingSignal(contents: string, modulePath: string): string { - const patchedContents = patchCode(contents, sharedModuleLoadingSignalRule); - if (patchedContents === contents) { + const trackedPromiseCount = contents.match(/\bmoduleLoadingSignal\.trackRead\s*\(/g)?.length ?? 0; + const trackedPromises = patchCode(contents, trackModuleLoadingPromiseRule); + if ( + trackedPromiseCount === 0 || + trackedPromises === contents || + (trackedPromises.match(/_moduleLoadingSignal\.add\s*\(/g)?.length ?? 0) !== trackedPromiseCount + ) { + throw new Error(`Failed to patch module promise tracking in ${modulePath}`); + } + + const patchedContents = patchCode(trackedPromises, requestScopedModuleLoadingSignalRule); + if (patchedContents === trackedPromises) { throw new Error(`Failed to patch the module loading signal in ${modulePath}`); } @@ -239,25 +249,15 @@ fix: |- `; /** - * Next.js tracks in-flight dynamic imports and chunk loads on a single module scoped `CacheSignal`. - * That signal stores `pendingTimeoutCleanup`, a closure over a `setImmediate` handle belonging to - * whichever request scheduled it. A Worker isolate serves many requests against that one instance, so - * the next request to import a module runs `beginRead()` and clears a handle owned by another request, - * which workerd rejects with "Cannot perform I/O on behalf of a different request". The throw escapes - * mid render, so that response never completes and the isolate keeps serving truncated bodies. - * - * The signal has to stay shared: an `import()` promise is commonly cached in user land, so only the - * first render executes the instrumented import and every later render has to learn about that pending - * promise from the shared signal (`subscribeToReads` replays in-flight reads to each new subscriber). - * Per-request signals would let a second render's `cacheReady()` resolve while a module it depends on - * is still loading. + * Next.js subscribes every render's `CacheSignal` to one module-scoped signal. Both signals store timer + * cleanup closures, so a later request notifying an older subscriber can clear a handle owned by that + * older request. workerd rejects the cross-request I/O and the render returns a truncated Flight stream. * - * Only the timer is request bound, so drop it: `trackPendingModules` subscribes render signals instead - * of registering listeners, so the shared signal has no listeners to notify and the scheduled callback - * only ever walks empty arrays. Keep scheduling when a listener does exist, so a future Next.js that - * awaits this signal directly fails loudly instead of silently never resolving. + * Keep the signals and subscriptions request scoped. A shared Set retains only plain import promises, + * so a later request can seed its own signal with imports that started elsewhere. Registering that + * promise on the request's signal gives its completion callback and timer the correct request context. */ -export const sharedModuleLoadingSignalRule = ` +export const requestScopedModuleLoadingSignalRule = ` rule: pattern: selector: function_declaration @@ -265,19 +265,36 @@ rule: fix: |- function $FUNCTION() { if (!$SIGNAL) { - $SIGNAL = new $CTOR(); - const sharedSignal = $SIGNAL; - const scheduleListenerNotification = sharedSignal.noMorePendingCaches.bind(sharedSignal); - sharedSignal.noMorePendingCaches = function () { - if (sharedSignal.listeners.length > 0 || sharedSignal.earlyListeners.length > 0) { - scheduleListenerNotification(); - } - }; + $SIGNAL = new Set(); } - return $SIGNAL; + + const requestScope = globalThis[Symbol.for("__cloudflare-context__")] ?? $SIGNAL; + if (!requestScope.__openNextModuleLoadingSignal) { + const requestModuleLoadingSignal = new $CTOR(); + for (const pendingModuleLoad of $SIGNAL) { + requestModuleLoadingSignal.trackRead(pendingModuleLoad); + } + requestScope.__openNextModuleLoadingSignal = requestModuleLoadingSignal; + } + return requestScope.__openNextModuleLoadingSignal; } `; +/** Record each import promise before attaching it to the current request's module loading signal. */ +export const trackModuleLoadingPromiseRule = ` +rule: + pattern: + selector: expression_statement + context: "$MODULE_LOADING_SIGNAL.trackRead($PROMISE);" +fix: |- + _moduleLoadingSignal.add($PROMISE); + $PROMISE.then( + () => _moduleLoadingSignal.delete($PROMISE), + () => _moduleLoadingSignal.delete($PROMISE) + ); + $MODULE_LOADING_SIGNAL.trackRead($PROMISE) +`; + /** * 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 From 5044b881e2bd070182bbefd302b50a7686a5901f Mon Sep 17 00:00:00 2001 From: Nathan Nguyen <146415969+NathanDrake2406@users.noreply.github.com> Date: Wed, 19 Aug 2026 16:23:10 +1000 Subject: [PATCH 09/14] fix(cache-components): keep future module loads request scoped Overlapping renders can subscribe before another request starts a cached import. A one-time pending-promise snapshot loses that future read and lets the render finish early. Forward new imports through request-owned promises, gate the rewrite to affected Next 16 releases, and fail closed without depending on the upstream backing identifier. --- .changeset/bright-caches-stream.md | 2 +- .../experimental/e2e/concurrent-rsc.test.ts | 5 +- .../cloudflare/src/cli/build/bundle-server.ts | 2 +- .../patches/plugins/cache-components.spec.ts | 84 +++++++++++- .../build/patches/plugins/cache-components.ts | 121 ++++++++++++++---- 5 files changed, 183 insertions(+), 31 deletions(-) diff --git a/.changeset/bright-caches-stream.md b/.changeset/bright-caches-stream.md index 1e842341c..5499a8a2d 100644 --- a/.changeset/bright-caches-stream.md +++ b/.changeset/bright-caches-stream.md @@ -6,4 +6,4 @@ 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 keep module loading `CacheSignal` instances and subscriptions request scoped. A shared set retains only pending import promises, so later requests still wait for user-land cached imports without clearing timer handles that belong to another request. +Also keep module loading `CacheSignal` instances and subscriptions request scoped. A shared promise registry forwards current and future imports through request-owned notifications, so overlapping renders wait without sharing timer handles. diff --git a/examples/e2e/experimental/e2e/concurrent-rsc.test.ts b/examples/e2e/experimental/e2e/concurrent-rsc.test.ts index 630f3da64..3b78bbe11 100644 --- a/examples/e2e/experimental/e2e/concurrent-rsc.test.ts +++ b/examples/e2e/experimental/e2e/concurrent-rsc.test.ts @@ -83,7 +83,10 @@ async function fetchPath( route: Route, kind: keyof typeof VARIANTS ): Promise { - const response = await request.get(route.path, { headers: VARIANTS[kind] }); + const response = await request.get(route.path, { + headers: VARIANTS[kind], + maxRedirects: 0, + }); return { route, kind, diff --git a/packages/cloudflare/src/cli/build/bundle-server.ts b/packages/cloudflare/src/cli/build/bundle-server.ts index 191a39abb..397bfc537 100644 --- a/packages/cloudflare/src/cli/build/bundle-server.ts +++ b/packages/cloudflare/src/cli/build/bundle-server.ts @@ -103,7 +103,7 @@ export async function bundleServer(buildOpts: BuildOptions, projectOpts: Project fixRequire(updater), handleOptionalDependencies(optionalDependencies), patchInstrumentation(updater, buildOpts), - patchCacheComponents(updater, nextConfig), + patchCacheComponents(updater, nextConfig, buildOpts.nextVersion), 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 index 3b1c3ed94..bb1175083 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 @@ -30,6 +30,11 @@ const incompatibleSchedulerPattern = /["']_idleStart["']\s*in/; const nextRequire = createRequire( new URL("../../../../../../../examples/e2e/experimental/package.json", import.meta.url) ); +const next15Require = createRequire( + new URL("../../../../../../../examples/playground15/package.json", import.meta.url) +); +const next15Version = (next15Require("next/package.json") as { version: string }).version; +const next15RuntimePath = next15Require.resolve("next/dist/compiled/next-server/app-page.runtime.prod.js"); const moduleTrackerPath = nextRequire.resolve( "next/dist/server/app-render/module-loading/track-module-loading.instance.js" ); @@ -47,8 +52,11 @@ type ModuleTracker = { }; /** Runs the patched copy of Next's real module tracker so its behaviour, not its text, is asserted. */ -function loadPatchedModuleTracker(): ModuleTracker { - const patched = patchModuleLoadingSignal(readFileSync(moduleTrackerPath, "utf8"), moduleTrackerPath); +function loadPatchedModuleTracker( + contents = readFileSync(moduleTrackerPath, "utf8"), + modulePath = moduleTrackerPath +): ModuleTracker { + const patched = patchModuleLoadingSignal(contents, modulePath); const module = { exports: {} as ModuleTracker }; new Function("require", "module", "exports", patched)(trackerRequire, module, module.exports); @@ -252,6 +260,37 @@ s.push(i(()=>{try{(0,oX.expectNoPendingImmediates)(),r(a)}catch(e){n(e)}}))})}`; }); }); + test("forwards an import that starts after another request subscribes", async () => { + const { trackPendingImport, trackPendingModules } = loadPatchedModuleTracker(); + + await withRequestScopes(async (enterRequest) => { + let settle: () => void = () => {}; + const cachedImport = new Promise((resolve) => (settle = resolve)); + + enterRequest("B"); + const renderB = new CacheSignal(); + trackPendingModules(renderB); + + // A starts the only instrumented import after B subscribed. B later reuses the user-land + // cached promise, so the module tracker must forward A's future read to B's request signal. + enterRequest("A"); + trackPendingImport(cachedImport); + enterRequest("B"); + await Promise.resolve(); + + expect(renderB.hasPendingReads()).toBe(true); + + let ready = false; + void renderB.cacheReady().then(() => (ready = true)); + await tick(); + expect(ready, "cacheReady must wait for a future import from another request").toBe(false); + + settle(); + await tick(); + expect(ready).toBe(true); + }); + }); + test("does not clear a timer handle owned by another request", async () => { const { trackPendingImport } = loadPatchedModuleTracker(); @@ -311,6 +350,11 @@ function getModuleLoadingSignal() { function trackPendingChunkLoad(promise) { const moduleLoadingSignal = getModuleLoadingSignal(); moduleLoadingSignal.trackRead(promise); +} +function trackPendingModules(cacheSignal) { + const moduleLoadingSignal = getModuleLoadingSignal(); + const unsubscribe = moduleLoadingSignal.subscribeToReads(cacheSignal); + cacheSignal.cacheReady().then(unsubscribe); }`; expect(() => patchModuleLoadingSignal(code, "changed-module-loading.js")).toThrow( @@ -318,6 +362,16 @@ function trackPendingChunkLoad(promise) { ); }); + test("does not depend on the module loading signal's backing identifier", () => { + const renamedSource = readFileSync(moduleTrackerPath, "utf8").replaceAll( + "_moduleLoadingSignal", + "renamedModuleLoadingSignal" + ); + const tracker = loadPatchedModuleTracker(renamedSource, "renamed-module-loading.js"); + + expect(() => tracker.trackPendingImport(Promise.resolve())).not.toThrow(); + }); + test("removes the separate atomic timer group emitted by webpack", () => { const unrelatedIdleStartCheck = `function inspectTimer(timer){return "_idleStart" in timer?timer._idleStart:null}`; const code = `function createGroup(){let didRun=false;return function schedule(callback){ @@ -448,18 +502,38 @@ ${unrelatedIdleStartCheck}`; const updateContent = vi.fn(); const updater = { updateContent } as unknown as ContentUpdater; - patchCacheComponents(updater, {} as NextConfig); + patchCacheComponents(updater, {} as NextConfig, "16.2.11"); expect(updateContent).not.toHaveBeenCalled(); - patchCacheComponents(updater, { cacheComponents: true } as NextConfig); + patchCacheComponents(updater, { cacheComponents: true } as NextConfig, "16.2.11"); expect(updateContent).toHaveBeenCalledWith("cache-components-scheduler", expect.anything()); expect(updateContent).toHaveBeenCalledWith("cache-components-module-loading-signal", expect.anything()); }); + test("does not require Next 16 patches for Next 15 Cache Components", () => { + expect(next15Version).toBe("15.5.21"); + expect(readFileSync(next15RuntimePath, "utf8")).not.toMatch( + /Cannot schedule more timers into a group that already executed/ + ); + + const updater = { updateContent: vi.fn() } as unknown as ContentUpdater; + const plugin = patchCacheComponents( + updater, + { experimental: { dynamicIO: true } } as NextConfig, + next15Version + ); + + expect(updater.updateContent).not.toHaveBeenCalled(); + + let onEnd: (result: { errors: unknown[] }) => void = () => {}; + plugin.setup({ onEnd: (callback: typeof onEnd) => (onEnd = callback) } as never); + expect(() => onEnd({ errors: [] })).not.toThrow(); + }); + // A filter that stops matching skips the callback silently, which would ship an unpatched build. test("fails the build when a patch matched nothing", () => { const updater = { updateContent: vi.fn() } as unknown as ContentUpdater; - const plugin = patchCacheComponents(updater, { cacheComponents: true } as NextConfig); + const plugin = patchCacheComponents(updater, { cacheComponents: true } as NextConfig, "16.2.11"); let onEnd: (result: { errors: unknown[] }) => void = () => {}; plugin.setup({ onEnd: (callback: typeof onEnd) => (onEnd = callback) } as never); 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 29e394ce6..a3f50d0e8 100644 --- a/packages/cloudflare/src/cli/build/patches/plugins/cache-components.ts +++ b/packages/cloudflare/src/cli/build/patches/plugins/cache-components.ts @@ -2,7 +2,7 @@ import { existsSync, readFileSync, writeFileSync } from "node:fs"; import path from "node:path"; import { loadConfig } from "@opennextjs/aws/adapters/config/util.js"; -import type { BuildOptions } from "@opennextjs/aws/build/helper.js"; +import { type BuildOptions, compareSemver } 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 type { NextConfig } from "@opennextjs/aws/types/next-types.js"; @@ -56,8 +56,12 @@ export const moduleLoadingSignalFileFilter = getCrossPlatformPathRegex( * fail-loudly errors) when the app actually enables Cache Components. Apps without the flag never * execute these code paths and must not have their builds fail when Next reshapes the internals. */ -export function patchCacheComponents(updater: ContentUpdater, nextConfig: NextConfig): Plugin { - if (!usesCacheComponents(nextConfig)) { +export function patchCacheComponents( + updater: ContentUpdater, + nextConfig: NextConfig, + nextVersion: string +): Plugin { + if (!usesCacheComponents(nextConfig) || compareSemver(nextVersion, "<", "16.2.11")) { return { name: "patch-cache-components", setup() {}, @@ -121,13 +125,18 @@ export function patchModuleLoadingSignal(contents: string, modulePath: string): if ( trackedPromiseCount === 0 || trackedPromises === contents || - (trackedPromises.match(/_moduleLoadingSignal\.add\s*\(/g)?.length ?? 0) !== trackedPromiseCount + (trackedPromises.match(/\.__openNextTrackModuleLoad\s*\(/g)?.length ?? 0) !== trackedPromiseCount ) { throw new Error(`Failed to patch module promise tracking in ${modulePath}`); } - const patchedContents = patchCode(trackedPromises, requestScopedModuleLoadingSignalRule); - if (patchedContents === trackedPromises) { + const forwardedPromises = patchCode(trackedPromises, forwardModuleLoadingPromisesRule); + if (forwardedPromises === trackedPromises) { + throw new Error(`Failed to patch module promise forwarding in ${modulePath}`); + } + + const patchedContents = patchCode(forwardedPromises, requestScopedModuleLoadingSignalRule); + if (patchedContents === forwardedPromises) { throw new Error(`Failed to patch the module loading signal in ${modulePath}`); } @@ -253,9 +262,9 @@ fix: |- * cleanup closures, so a later request notifying an older subscriber can clear a handle owned by that * older request. workerd rejects the cross-request I/O and the render returns a truncated Flight stream. * - * Keep the signals and subscriptions request scoped. A shared Set retains only plain import promises, - * so a later request can seed its own signal with imports that started elsewhere. Registering that - * promise on the request's signal gives its completion callback and timer the correct request context. + * Keep the signals and subscriptions request scoped. A shared registry retains plain import promises + * and resolves request-owned notification promises when new imports start. Each notification resumes + * in the subscriber's request before it touches that request's signal or timer handles. */ export const requestScopedModuleLoadingSignalRule = ` rule: @@ -265,34 +274,100 @@ rule: fix: |- function $FUNCTION() { if (!$SIGNAL) { - $SIGNAL = new Set(); + $SIGNAL = { + pendingModuleLoads: new Set(), + moduleLoadSubscribers: new Set(), + requestSignals: new WeakMap(), + }; } - const requestScope = globalThis[Symbol.for("__cloudflare-context__")] ?? $SIGNAL; - if (!requestScope.__openNextModuleLoadingSignal) { - const requestModuleLoadingSignal = new $CTOR(); - for (const pendingModuleLoad of $SIGNAL) { - requestModuleLoadingSignal.trackRead(pendingModuleLoad); - } - requestScope.__openNextModuleLoadingSignal = requestModuleLoadingSignal; + const requestScope = globalThis[Symbol.for("__cloudflare-context__")] ?? globalThis; + let requestModuleLoadingSignal = $SIGNAL.requestSignals.get(requestScope); + if (!requestModuleLoadingSignal) { + requestModuleLoadingSignal = new $CTOR(); + const trackedModuleLoads = new Set(); + + requestModuleLoadingSignal.__openNextModuleLoadingRegistry = $SIGNAL; + requestModuleLoadingSignal.__openNextTrackModuleLoad = function (promise) { + if (trackedModuleLoads.has(promise)) return; + + trackedModuleLoads.add(promise); + promise.then( + () => trackedModuleLoads.delete(promise), + () => trackedModuleLoads.delete(promise) + ); + requestModuleLoadingSignal.trackRead(promise); + }; + $SIGNAL.requestSignals.set(requestScope, requestModuleLoadingSignal); + } + + for (const pendingModuleLoad of $SIGNAL.pendingModuleLoads) { + requestModuleLoadingSignal.__openNextTrackModuleLoad(pendingModuleLoad); } - return requestScope.__openNextModuleLoadingSignal; + return requestModuleLoadingSignal; } `; -/** Record each import promise before attaching it to the current request's module loading signal. */ +/** Record and announce each import before attaching it to the current request's signal. */ export const trackModuleLoadingPromiseRule = ` rule: pattern: selector: expression_statement context: "$MODULE_LOADING_SIGNAL.trackRead($PROMISE);" fix: |- - _moduleLoadingSignal.add($PROMISE); + $MODULE_LOADING_SIGNAL.__openNextModuleLoadingRegistry.pendingModuleLoads.add($PROMISE); $PROMISE.then( - () => _moduleLoadingSignal.delete($PROMISE), - () => _moduleLoadingSignal.delete($PROMISE) + () => $MODULE_LOADING_SIGNAL.__openNextModuleLoadingRegistry.pendingModuleLoads.delete($PROMISE), + () => $MODULE_LOADING_SIGNAL.__openNextModuleLoadingRegistry.pendingModuleLoads.delete($PROMISE) ); - $MODULE_LOADING_SIGNAL.trackRead($PROMISE) + for (const notifyModuleLoad of $MODULE_LOADING_SIGNAL.__openNextModuleLoadingRegistry.moduleLoadSubscribers) { + notifyModuleLoad($PROMISE); + } + $MODULE_LOADING_SIGNAL.__openNextTrackModuleLoad($PROMISE) +`; + +/** Forward future imports through promises created and observed by the subscribing request. */ +export const forwardModuleLoadingPromisesRule = ` +rule: + pattern: + selector: lexical_declaration + context: "const $UNSUBSCRIBE = $MODULE_LOADING_SIGNAL.subscribeToReads($CACHE_SIGNAL);" +fix: |- + const openNextModuleLoadingRegistry = $MODULE_LOADING_SIGNAL.__openNextModuleLoadingRegistry; + const openNextQueuedModuleLoads = []; + let openNextSubscriptionActive = true; + let openNextResolveNotification; + + function openNextWaitForModuleLoads() { + const notification = new Promise((resolve) => { + openNextResolveNotification = resolve; + }); + void notification.then(() => { + openNextResolveNotification = undefined; + if (!openNextSubscriptionActive) return; + + for (const promise of openNextQueuedModuleLoads.splice(0)) { + $MODULE_LOADING_SIGNAL.__openNextTrackModuleLoad(promise); + } + openNextWaitForModuleLoads(); + }); + } + + openNextWaitForModuleLoads(); + const openNextNotifyModuleLoad = (promise) => { + if (!openNextSubscriptionActive) return; + openNextQueuedModuleLoads.push(promise); + openNextResolveNotification(); + }; + openNextModuleLoadingRegistry.moduleLoadSubscribers.add(openNextNotifyModuleLoad); + + const openNextUnsubscribe = $MODULE_LOADING_SIGNAL.subscribeToReads($CACHE_SIGNAL); + const $UNSUBSCRIBE = () => { + openNextSubscriptionActive = false; + openNextModuleLoadingRegistry.moduleLoadSubscribers.delete(openNextNotifyModuleLoad); + openNextResolveNotification(); + openNextUnsubscribe(); + }; `; /** From 940d2bbdd3d6020e7f1c6af76e4024c775bbc132 Mon Sep 17 00:00:00 2001 From: Nathan Nguyen <146415969+NathanDrake2406@users.noreply.github.com> Date: Wed, 19 Aug 2026 23:24:12 +1000 Subject: [PATCH 10/14] fix(cache-components): settle each staged render task on workerd --- .changeset/bright-caches-stream.md | 2 +- .../experimental/e2e/staged-render.test.ts | 97 +++++++++++ .../src/app/deep-shell/[slug]/page.tsx | 63 +++++++ .../src/app/runtime-prefetch/[slug]/page.tsx | 59 +++++++ .../cloudflare/src/cli/build/bundle-server.ts | 2 +- .../patches/plugins/cache-components.spec.ts | 117 ++++++------- .../build/patches/plugins/cache-components.ts | 78 +++------ .../cache-components-scheduler.spec.ts | 162 ++++++++++++++++++ .../templates/cache-components-scheduler.ts | 146 ++++++++++++++++ 9 files changed, 605 insertions(+), 121 deletions(-) create mode 100644 examples/e2e/experimental/e2e/staged-render.test.ts create mode 100644 examples/e2e/experimental/src/app/deep-shell/[slug]/page.tsx create mode 100644 examples/e2e/experimental/src/app/runtime-prefetch/[slug]/page.tsx create mode 100644 packages/cloudflare/src/cli/templates/cache-components-scheduler.spec.ts create mode 100644 packages/cloudflare/src/cli/templates/cache-components-scheduler.ts diff --git a/.changeset/bright-caches-stream.md b/.changeset/bright-caches-stream.md index 5499a8a2d..1bc0d4fe6 100644 --- a/.changeset/bright-caches-stream.md +++ b/.changeset/bright-caches-stream.md @@ -4,6 +4,6 @@ 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. +Next.js renders Cache Components as a pipeline of event loop tasks and, between two of them, drains the immediates React queued so each stage flushes before the next unblocks more content. Its Node.js implementation builds that boundary out of `_idleStart` timer alignment and `process.nextTick`, neither of which behaves the same on workerd, so the render lands a stage late: runtime prefetches drop everything that arrives after their final task aborts the render, and document renders report cached data as uncached and fail. Replace the staged runner with a workerd implementation that waits for a render's own immediates to settle before entering the next stage, and let Next.js resume partially prerendered routes instead of returning their cached shell as a complete response. Also keep module loading `CacheSignal` instances and subscriptions request scoped. A shared promise registry forwards current and future imports through request-owned notifications, so overlapping renders wait without sharing timer handles. diff --git a/examples/e2e/experimental/e2e/staged-render.test.ts b/examples/e2e/experimental/e2e/staged-render.test.ts new file mode 100644 index 000000000..01ef200f4 --- /dev/null +++ b/examples/e2e/experimental/e2e/staged-render.test.ts @@ -0,0 +1,97 @@ +import { expect, test, type APIRequestContext } from "@playwright/test"; + +/** + * Next.js renders Cache Components as a pipeline of event loop tasks and expects React to flush each + * stage before the next one unblocks more content. Its Node.js implementation gets that boundary from + * `process.nextTick`; workerd implements `process.nextTick` with `queueMicrotask`, so the adapter has + * to supply the boundary itself. When it slips, the render lands a stage late: a runtime prefetch + * drops everything that arrives after its final task aborts the render, and a document render reports + * cached data as uncached and fails with a 500. + * + * `/deep-shell/[slug]` is built for this: its shell awaits many times before rendering, so the flush + * that a broken boundary loses is the shell itself. + */ + +const RUNTIME_PREFETCH = { rsc: "1", "next-router-prefetch": "2" }; +const ROUTE_PREFETCH = { rsc: "1", "next-router-prefetch": "1" }; +const SEGMENT_PREFETCH = { ...ROUTE_PREFETCH, "next-router-segment-prefetch": "/_tree" }; + +/** Runtime prefetches start with `~` when partial and `#` when complete; anything else is not one. */ +function expectRuntimePrefetch(body: Buffer, where: string) { + expect(body.byteLength, `${where} carried only the partial marker`).toBeGreaterThan(1); + expect(String.fromCharCode(body[0]!), `${where} should start with a partial marker`).toMatch(/^[~#]$/); +} + +async function runtimePrefetch(request: APIRequestContext, path: string, session: string) { + const response = await request.get(path, { + headers: { ...RUNTIME_PREFETCH, "x-session": session }, + }); + expect(response.status(), `runtime prefetch ${path} should complete`).toEqual(200); + expect(response.headers()["content-type"]).toContain("text/x-component"); + return response; +} + +test.describe("staged Cache Components rendering", () => { + test("a runtime prefetch carries the shell it rendered", async ({ request }) => { + const response = await runtimePrefetch(request, "/runtime-prefetch/one", "xyz"); + const body = await response.body(); + + expectRuntimePrefetch(body, "/runtime-prefetch/one"); + const text = body.toString("utf8"); + expect(text).toContain("Runtime shell"); + // Session content resolves in a later stage than the shell, so it proves the pipeline advanced. + expect(text).toContain("Runtime session: "); + expect(text).toContain("xyz"); + }); + + test("a deep shell reaches the client instead of being cut off mid render", async ({ request }) => { + const response = await runtimePrefetch(request, "/deep-shell/one", "abc"); + const body = await response.body(); + + expectRuntimePrefetch(body, "/deep-shell/one"); + const text = body.toString("utf8"); + // The leaf is the last thing the shell renders: a render that lands a stage late loses it. + expect(text).toContain("deep-leaf"); + expect(text).toContain("level 0"); + expect(text).toContain("Deep session: "); + expect(text).toContain("abc"); + }); + + test("a deep shell renders its document and prefetches", async ({ request }) => { + const document = await request.get("/deep-shell/two", { headers: { "x-session": "doc" } }); + const html = await document.text(); + + expect(document.status()).toEqual(200); + expect(html).toContain(""); + expect(html.replaceAll("", "")).toContain("Deep leaf level 0"); + // The dynamic hole resolves through a streamed Flight row, so its text arrives JSON escaped. + expect(html).toContain("deep-dynamic"); + expect(html).toContain("Deep dynamic: "); + + for (const headers of [ROUTE_PREFETCH, SEGMENT_PREFETCH]) { + const response = await request.get("/deep-shell/two", { headers }); + + expect(response.status(), `prefetch ${JSON.stringify(headers)} should complete`).toEqual(200); + expect(response.headers()["content-type"]).toContain("text/x-component"); + expect((await response.body()).byteLength).toBeGreaterThan(1); + } + }); + + test("overlapping runtime prefetches each keep their own shell", async ({ request }) => { + const sessions = ["a", "b", "c", "d", "e", "f"]; + const responses = await Promise.all( + sessions.map((session) => runtimePrefetch(request, "/deep-shell/one", session)) + ); + + for (const [index, response] of responses.entries()) { + const body = await response.body(); + const where = `overlapping prefetch ${index}`; + + expectRuntimePrefetch(body, where); + const text = body.toString("utf8"); + expect(text, `${where} lost its deep shell`).toContain("deep-leaf"); + expect(text, `${where} lost its session content`).toContain(`Deep session: `); + expect(text, `${where} served another request's session`).toContain(sessions[index]!); + } + }); +}); diff --git a/examples/e2e/experimental/src/app/deep-shell/[slug]/page.tsx b/examples/e2e/experimental/src/app/deep-shell/[slug]/page.tsx new file mode 100644 index 000000000..fe036d794 --- /dev/null +++ b/examples/e2e/experimental/src/app/deep-shell/[slug]/page.tsx @@ -0,0 +1,63 @@ +import { headers } from "next/headers"; +import { setTimeout } from "node:timers/promises"; +import { Suspense } from "react"; + +type PageProps = { params: Promise<{ slug: string }> }; + +export const unstable_instant = { + prefetch: "runtime", + samples: [{ params: { slug: "sample" }, headers: [["x-session", "sample"]] }], + unstable_disableBuildValidation: true, +}; + +async function cachedLabel(level: number) { + "use cache"; + return `level ${level}`; +} + +/** Each level awaits several times before rendering, pushing React's first flush away from the task boundary. */ +async function Level({ depth }: { depth: number }) { + for (let i = 0; i < 6; i++) await Promise.resolve(); + const label = await cachedLabel(depth); + for (let i = 0; i < 6; i++) await Promise.resolve(); + + if (depth === 0) return

Deep leaf {label}

; + return ( +
+ {label} + +
+ ); +} + +async function DeepSession() { + for (let i = 0; i < 8; i++) await Promise.resolve(); + const requestHeaders = await headers(); + for (let i = 0; i < 8; i++) await Promise.resolve(); + + return

Deep session: {requestHeaders.get("x-session") ?? "none"}

; +} + +async function DeepDynamic({ params }: PageProps) { + const [{ slug }] = await Promise.all([params, headers()]); + await setTimeout(50); + + return

Deep dynamic: {slug}

; +} + +export default async function DeepShellPage({ params }: PageProps) { + for (let i = 0; i < 4; i++) await Promise.resolve(); + + return ( +
+

Deep shell

+ + Loading deep session...

}> + +
+ Loading deep dynamic...

}> + +
+
+ ); +} diff --git a/examples/e2e/experimental/src/app/runtime-prefetch/[slug]/page.tsx b/examples/e2e/experimental/src/app/runtime-prefetch/[slug]/page.tsx new file mode 100644 index 000000000..08693fd00 --- /dev/null +++ b/examples/e2e/experimental/src/app/runtime-prefetch/[slug]/page.tsx @@ -0,0 +1,59 @@ +import { headers } from "next/headers"; +import { setTimeout } from "node:timers/promises"; +import { Suspense } from "react"; + +type PageProps = { + params: Promise<{ slug: string }>; +}; + +/** + * A runtime prefetch renders the shell and the session content in a five task pipeline and aborts + * right after the last task, so anything React has not flushed by then is dropped and the client + * receives only the one byte partial marker. It is the strictest user of Next's staged scheduler. + */ +export const unstable_instant = { + prefetch: "runtime", + samples: [{ params: { slug: "sample" }, headers: [["x-session", "sample"]] }], + // Build time validation renders the page in a worker, which is not what this fixture exercises. + unstable_disableBuildValidation: true, +}; + +async function getShellLabel() { + "use cache"; + return "Runtime shell"; +} + +async function RuntimeShell() { + const label = await getShellLabel(); + + return

{label}

; +} + +async function RuntimeSession() { + const requestHeaders = await headers(); + + return

Runtime session: {requestHeaders.get("x-session") ?? "none"}

; +} + +async function RuntimeDynamic({ params }: PageProps) { + const [{ slug }] = await Promise.all([params, headers()]); + await setTimeout(50); + + return

Runtime dynamic: {slug}

; +} + +export default async function RuntimePrefetchPage({ params }: PageProps) { + await Promise.resolve(); + + return ( +
+ + Loading runtime session...

}> + +
+ Loading runtime dynamic...

}> + +
+
+ ); +} diff --git a/packages/cloudflare/src/cli/build/bundle-server.ts b/packages/cloudflare/src/cli/build/bundle-server.ts index 397bfc537..6df204997 100644 --- a/packages/cloudflare/src/cli/build/bundle-server.ts +++ b/packages/cloudflare/src/cli/build/bundle-server.ts @@ -103,7 +103,7 @@ export async function bundleServer(buildOpts: BuildOptions, projectOpts: Project fixRequire(updater), handleOptionalDependencies(optionalDependencies), patchInstrumentation(updater, buildOpts), - patchCacheComponents(updater, nextConfig, buildOpts.nextVersion), + patchCacheComponents(updater, buildOpts, nextConfig), 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 index bb1175083..aa5faebea 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 @@ -1,5 +1,6 @@ import { readFileSync } from "node:fs"; import { createRequire } from "node:module"; +import { join } from "node:path"; import type { BuildOptions } from "@opennextjs/aws/build/helper.js"; import type { ContentUpdater } from "@opennextjs/aws/plugins/content-updater.js"; @@ -11,6 +12,7 @@ import { computePatchDiff } from "../../utils/test-patch.js"; import { bypassPprCacheInterceptionRule, cacheComponentsSchedulerFileFilter, + cacheComponentsSchedulerModule, moduleLoadingSignalFileFilter, patchCacheComponents, patchCacheComponentsScheduler, @@ -100,6 +102,32 @@ async function withRequestScopes( } } +function buildOptsFor(nextVersion: string): BuildOptions { + return { nextVersion, outputDir: "/output" } as BuildOptions; +} + +type PluginHarness = { + onEnd: (result: { errors: unknown[] }) => void; + resolve: (specifier: string) => { path: string } | undefined; +}; + +/** Captures the esbuild callbacks the plugin registers so they can be exercised directly. */ +function setupPlugin(plugin: ReturnType): PluginHarness { + const resolvers: Array<[RegExp, (args: { path: string }) => { path: string }]> = []; + let onEnd: PluginHarness["onEnd"] = () => {}; + + plugin.setup({ + onEnd: (callback: PluginHarness["onEnd"]) => (onEnd = callback), + onResolve: (options: { filter: RegExp }, callback: (args: { path: string }) => { path: string }) => + resolvers.push([options.filter, callback]), + } as never); + + return { + onEnd: (result) => onEnd(result), + resolve: (specifier) => resolvers.find(([filter]) => filter.test(specifier))?.[1]({ path: specifier }), + }; +} + function readSchedulerFixture(name: string): string { return readFileSync(new URL(`./fixtures/cache-components/${name}`, import.meta.url), "utf8"); } @@ -121,7 +149,7 @@ s.push(i(()=>{try{(0,oX.expectNoPendingImmediates)(),r(a)}catch(e){n(e)}}))})}`; =================================================================== --- app-render-render-utils.js +++ app-render-render-utils.js - @@ -1,6 +1,48 @@ + @@ -1,6 +1,4 @@ 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; @@ -130,51 +158,7 @@ s.push(i(()=>{try{(0,oX.expectNoPendingImmediates)(),r(a)}catch(e){n(e)}}))})}`; -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); - + } - + }); - + }); + + return require("__opennext_cache_components_scheduler").runInSequentialTasks(first, ...rest); +} \\ No newline at end of file " @@ -190,7 +174,7 @@ s.push(i(()=>{try{(0,oX.expectNoPendingImmediates)(),r(a)}catch(e){n(e)}}))})}`; expect(patched).not.toBe(code); expect(patched).not.toMatch(incompatibleSchedulerPattern); - expect(patched).toContain("workerdFastSetImmediate.unpatchedSetImmediate"); + expect(patched).toContain(`require("${cacheComponentsSchedulerModule}").runInSequentialTasks`); }); test.each([ @@ -392,7 +376,7 @@ ${unrelatedIdleStartCheck}`; 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"); + expect(patched).toContain(`require("${cacheComponentsSchedulerModule}").runInSequentialTasks`); }); test("fails when an incompatible scheduler is present but cannot be patched", () => { @@ -502,10 +486,10 @@ ${unrelatedIdleStartCheck}`; const updateContent = vi.fn(); const updater = { updateContent } as unknown as ContentUpdater; - patchCacheComponents(updater, {} as NextConfig, "16.2.11"); + patchCacheComponents(updater, buildOptsFor("16.2.11"), {} as NextConfig); expect(updateContent).not.toHaveBeenCalled(); - patchCacheComponents(updater, { cacheComponents: true } as NextConfig, "16.2.11"); + patchCacheComponents(updater, buildOptsFor("16.2.11"), { cacheComponents: true } as NextConfig); expect(updateContent).toHaveBeenCalledWith("cache-components-scheduler", expect.anything()); expect(updateContent).toHaveBeenCalledWith("cache-components-module-loading-signal", expect.anything()); }); @@ -517,29 +501,36 @@ ${unrelatedIdleStartCheck}`; ); const updater = { updateContent: vi.fn() } as unknown as ContentUpdater; - const plugin = patchCacheComponents( - updater, - { experimental: { dynamicIO: true } } as NextConfig, - next15Version - ); + const plugin = patchCacheComponents(updater, buildOptsFor(next15Version), { + experimental: { dynamicIO: true }, + } as NextConfig); expect(updater.updateContent).not.toHaveBeenCalled(); - - let onEnd: (result: { errors: unknown[] }) => void = () => {}; - plugin.setup({ onEnd: (callback: typeof onEnd) => (onEnd = callback) } as never); - expect(() => onEnd({ errors: [] })).not.toThrow(); + expect(() => setupPlugin(plugin).onEnd({ errors: [] })).not.toThrow(); }); // A filter that stops matching skips the callback silently, which would ship an unpatched build. test("fails the build when a patch matched nothing", () => { const updater = { updateContent: vi.fn() } as unknown as ContentUpdater; - const plugin = patchCacheComponents(updater, { cacheComponents: true } as NextConfig, "16.2.11"); - - let onEnd: (result: { errors: unknown[] }) => void = () => {}; - plugin.setup({ onEnd: (callback: typeof onEnd) => (onEnd = callback) } as never); + const plugin = patchCacheComponents(updater, buildOptsFor("16.2.11"), { + cacheComponents: true, + } as NextConfig); + const { onEnd } = setupPlugin(plugin); expect(() => onEnd({ errors: [] })).toThrow(/scheduler and module loading signal patches/); // A build that already failed keeps its own error. expect(() => onEnd({ errors: [{ text: "something else broke" }] })).not.toThrow(); }); + // The generated `require` must resolve to the adapter's own scheduler, not to a missing package. + test("resolves the scheduler module to the copied template", () => { + const plugin = patchCacheComponents( + { updateContent: vi.fn() } as unknown as ContentUpdater, + buildOptsFor("16.2.11"), + { cacheComponents: true } as NextConfig + ); + + expect(setupPlugin(plugin).resolve(cacheComponentsSchedulerModule)?.path).toBe( + join("/output", "cloudflare-templates/cache-components-scheduler.js") + ); + }); }); 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 a3f50d0e8..660fd35cd 100644 --- a/packages/cloudflare/src/cli/build/patches/plugins/cache-components.ts +++ b/packages/cloudflare/src/cli/build/patches/plugins/cache-components.ts @@ -28,6 +28,12 @@ export function usesCacheComponents(nextConfig: CacheComponentsNextConfig): bool ); } +/** + * Bare specifier the patched scheduler requires. `patchCacheComponents` resolves it to the copied + * `cache-components-scheduler` template, so the generated code carries no build machine paths. + */ +export const cacheComponentsSchedulerModule = "__opennext_cache_components_scheduler"; + const atomicTimerGroupErrorPattern = /Cannot schedule more timers into a group that already executed/; const moduleLoadingSignalPattern = /moduleLoadingSignal/; @@ -43,14 +49,12 @@ export const moduleLoadingSignalFileFilter = getCrossPlatformPathRegex( ); /** - * 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. + * Next.js stages Cache Components renders across event loop tasks. Its Node.js implementation keeps + * the stages in one timer phase by mutating each timer's private `_idleStart` field, and drains the + * render's immediates between two stages through `process.nextTick`. Neither works on workerd: timer + * handles have no `_idleStart`, and `process.nextTick` is `queueMicrotask`, so the drain ends before + * React has scheduled its flush. Replace the staged runner with a workerd implementation and leave + * the now unreachable timer group as a fail-fast stub. * * The matched code ships in every Next 16.2+ bundle, so only register the patches (and their * fail-loudly errors) when the app actually enables Cache Components. Apps without the flag never @@ -58,16 +62,18 @@ export const moduleLoadingSignalFileFilter = getCrossPlatformPathRegex( */ export function patchCacheComponents( updater: ContentUpdater, - nextConfig: NextConfig, - nextVersion: string + buildOpts: BuildOptions, + nextConfig: NextConfig ): Plugin { - if (!usesCacheComponents(nextConfig) || compareSemver(nextVersion, "<", "16.2.11")) { + if (!usesCacheComponents(nextConfig) || compareSemver(buildOpts.nextVersion, "<", "16.2.11")) { return { name: "patch-cache-components", setup() {}, }; } + const schedulerPath = path.join(buildOpts.outputDir, "cloudflare-templates/cache-components-scheduler.js"); + // `ContentUpdater` skips a callback when the file filter or the content filter stops matching, so a // renamed error message or a moved file would ship an unpatched build with no error at all. const applied = { scheduler: false, "module loading signal": false }; @@ -97,6 +103,10 @@ export function patchCacheComponents( return { name: "patch-cache-components", setup(build) { + build.onResolve({ filter: new RegExp(`^${cacheComponentsSchedulerModule}$`) }, () => ({ + path: schedulerPath, + })); + build.onEnd((result) => { // Another plugin already failed the build, so do not bury its error under ours. if (result.errors.length > 0) { @@ -209,51 +219,7 @@ rule: 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); - } - }); - }); + return require("${cacheComponentsSchedulerModule}").runInSequentialTasks(first, ...rest); } `; diff --git a/packages/cloudflare/src/cli/templates/cache-components-scheduler.spec.ts b/packages/cloudflare/src/cli/templates/cache-components-scheduler.spec.ts new file mode 100644 index 000000000..6233505b8 --- /dev/null +++ b/packages/cloudflare/src/cli/templates/cache-components-scheduler.spec.ts @@ -0,0 +1,162 @@ +import { afterAll, describe, expect, it } from "vitest"; + +import { runInSequentialTasks } from "./cache-components-scheduler.js"; + +const nativeSetImmediate = globalThis.setImmediate; +const nativeClearImmediate = globalThis.clearImmediate; + +afterAll(() => { + globalThis.setImmediate = nativeSetImmediate; + globalThis.clearImmediate = nativeClearImmediate; +}); + +/** + * Stands in for React Flight: a stage unblocks a gate, the component resumes across `hops` microtask + * hops, and only then schedules the flush through `setImmediate`. Next's contract is that both the + * work and its flush land before the next stage runs. + */ +function createGatedRender(stages: number, hops: number, log: string[]) { + const gates = Array.from({ length: stages }, () => { + let open!: () => void; + const opened = new Promise((resolve) => (open = resolve)); + return { opened, open }; + }); + + return { + start() { + for (const [stage, gate] of gates.entries()) { + void gate.opened.then(async () => { + for (let hop = 0; hop < hops; hop++) await null; + setImmediate(() => { + log.push(`work${stage}`); + setImmediate(() => log.push(`flush${stage}`)); + }); + }); + } + }, + advance: (stage: number) => gates[stage]!.open(), + }; +} + +function runGatedRender(stages: number, hops: number, log: string[]) { + const render = createGatedRender(stages, hops, log); + return runInSequentialTasks( + () => { + render.start(); + return "rendered"; + }, + ...Array.from({ length: stages }, (_, stage) => () => { + log.push(`stage${stage}`); + render.advance(stage); + }) + ); +} + +describe("runInSequentialTasks", () => { + it("resolves with what the first task returned", async () => { + const order: string[] = []; + const result = await runInSequentialTasks( + () => { + order.push("first"); + return 42; + }, + () => order.push("second"), + () => order.push("third") + ); + + expect(result).toEqual(42); + expect(order).toEqual(["first", "second", "third"]); + }); + + it("adopts a promise returned by the first task", async () => { + await expect( + runInSequentialTasks( + async () => "async result", + () => {} + ) + ).resolves.toEqual("async result"); + }); + + // The regression: on workerd the render's flush used to slip behind the stage that unblocked it, + // so a runtime prefetch aborted before the content it had already rendered was collected. + it.each([0, 1, 3, 12])( + "flushes each stage's work before the next stage, %i microtask hops in", + async (hops) => { + const log: string[] = []; + + await runGatedRender(4, hops, log); + + expect(log).toEqual([ + "stage0", + "work0", + "flush0", + "stage1", + "work1", + "flush1", + "stage2", + "work2", + "flush2", + "stage3", + "work3", + "flush3", + ]); + } + ); + + it("keeps overlapping renders from gating each other", async () => { + const slowLog: string[] = []; + const fastLog: string[] = []; + + // The slow render keeps scheduling immediates long after the fast one is done, which must not + // hold the fast render's stages back. + await Promise.all([runGatedRender(4, 12, slowLog), runGatedRender(4, 0, fastLog)]); + + for (const log of [slowLog, fastLog]) { + expect(log).toEqual([ + "stage0", + "work0", + "flush0", + "stage1", + "work1", + "flush1", + "stage2", + "work2", + "flush2", + "stage3", + "work3", + "flush3", + ]); + } + }); + + it("rejects and skips the remaining tasks when a task throws", async () => { + const order: string[] = []; + const failure = new Error("stage failed"); + + await expect( + runInSequentialTasks( + () => order.push("first"), + () => { + throw failure; + }, + () => order.push("never") + ) + ).rejects.toBe(failure); + + expect(order).toEqual(["first"]); + }); + + it("does not wait on an immediate that was cleared", async () => { + const log: string[] = []; + + await runInSequentialTasks( + () => { + const immediate = setImmediate(() => log.push("cleared")); + clearImmediate(immediate); + }, + () => log.push("stage1") + ); + + expect(log).toEqual(["stage1"]); + }); +}); diff --git a/packages/cloudflare/src/cli/templates/cache-components-scheduler.ts b/packages/cloudflare/src/cli/templates/cache-components-scheduler.ts new file mode 100644 index 000000000..d64956fcb --- /dev/null +++ b/packages/cloudflare/src/cli/templates/cache-components-scheduler.ts @@ -0,0 +1,146 @@ +/** + * Cache Components staged rendering for workerd. + * + * Next renders Cache Components as a pipeline of tasks and, between two of them, runs every + * immediate the previous task queued so React flushes that stage before the next one unblocks more + * content. On Node it gets that boundary from `process.nextTick`, which runs once the microtask + * queue is exhausted. workerd implements `process.nextTick` with `queueMicrotask`, so Next's drain + * gives up two microtask hops in - before React has scheduled its flush - and the render slips a + * stage behind. Runtime prefetches drop every chunk that arrives after their last task aborts the + * render, so the slip reaches the client as a prefetch body holding only the one byte partial + * marker, and a full render loses whatever the last stage produced. + * + * workerd runs timers and immediates from a single ordered macrotask queue and always drains + * microtasks between two of them, so scheduling an immediate is an exact "everything queued before + * me has run" signal. Count the immediates each staged render causes and hop until that count + * reaches zero before entering the next stage. Next's own fast-immediate capture is never engaged, + * which also keeps its process-wide capture slot free while requests overlap. + */ + +import { AsyncLocalStorage } from "node:async_hooks"; + +type StagedRun = { pending: number }; + +type ScheduleMacrotask = (callback: () => void) => unknown; + +/** Attributes an immediate to the staged render that caused it, so one render never waits on another. */ +const runStorage = new AsyncLocalStorage(); + +/** + * An immediate we never see settle - one cleared through a handle we did not hand out, or a render + * that never stops scheduling - must delay a stage rather than stall the pipeline for good. + */ +const MAX_SETTLE_HOPS = 1000; + +const COUNTED = Symbol.for("__opennext.cache-components.countedSetImmediate"); + +let scheduleMacrotask: ScheduleMacrotask | undefined; + +/** + * Count the immediates of the running staged render. Next patches `setImmediate` when its server + * environment loads, so wrap whatever is installed and keep delegating to it. + */ +function install(): ScheduleMacrotask { + const current = globalThis.setImmediate as typeof setImmediate & { [COUNTED]?: true }; + if (scheduleMacrotask && current[COUNTED]) { + return scheduleMacrotask; + } + + const previousSetImmediate = globalThis.setImmediate; + const previousClearImmediate = globalThis.clearImmediate; + const releaseByImmediate = new WeakMap void>(); + + const countedSetImmediate = (callback: (...args: unknown[]) => void, ...args: unknown[]) => { + const run = runStorage.getStore(); + if (!run) { + return previousSetImmediate(callback, ...args); + } + + run.pending++; + let released = false; + const release = () => { + if (!released) { + released = true; + run.pending--; + } + }; + + const immediate = previousSetImmediate(() => { + release(); + callback(...args); + }); + if (typeof immediate === "object" && immediate !== null) { + releaseByImmediate.set(immediate, release); + } + return immediate; + }; + Object.defineProperty(countedSetImmediate, COUNTED, { value: true }); + + const countedClearImmediate = (immediate: unknown) => { + if (typeof immediate === "object" && immediate !== null) { + releaseByImmediate.get(immediate)?.(); + } + return previousClearImmediate(immediate as Parameters[0]); + }; + + globalThis.setImmediate = countedSetImmediate as unknown as typeof setImmediate; + globalThis.clearImmediate = countedClearImmediate as unknown as typeof clearImmediate; + + // Hops must not count themselves, so they go through the unwrapped function. + scheduleMacrotask = previousSetImmediate as unknown as ScheduleMacrotask; + return scheduleMacrotask; +} + +function ignore(): void {} + +/** + * Drop-in replacement for Next's `runInSequentialTasks`: run `first`, then each of `rest` in its own + * task, and resolve with whatever `first` returned once the last task has settled. + */ +export function runInSequentialTasks(first: () => T, ...rest: Array<() => void>): Promise { + const hop = install(); + const run: StagedRun = { pending: 0 }; + + return new Promise((resolve, reject) => { + let result: T; + let stage = 0; + let hops = 0; + + const settleThen = (next: () => void) => { + hop(() => { + if (run.pending > 0 && hops++ < MAX_SETTLE_HOPS) { + settleThen(next); + return; + } + next(); + }); + }; + + const enterStage = () => { + try { + runStorage.run(run, () => { + if (stage === 0) { + result = first(); + // A later task may reject this; the caller observes it through the returned promise. + const thenable = result as PromiseLike | null | undefined; + if (thenable && typeof thenable.then === "function") { + thenable.then(ignore, ignore); + } + } else { + rest[stage - 1]!(); + } + }); + } catch (error) { + reject(error); + return; + } + + stage++; + hops = 0; + settleThen(stage > rest.length ? () => resolve(result) : enterStage); + }; + + // Start from a fresh task, the way Next's first timer does. + hop(enterStage); + }); +} From 4c7ccd2b735322439fdd2c86e9a41dea25186d2e Mon Sep 17 00:00:00 2001 From: Nathan Nguyen <146415969+NathanDrake2406@users.noreply.github.com> Date: Wed, 19 Aug 2026 23:42:55 +1000 Subject: [PATCH 11/14] fix(cache-components): fail a staged render that never settles Exhausting the settle budget entered the next stage anyway, which is the truncated response this scheduler exists to prevent, only silent. Reject instead so unsettled work surfaces as an error. That makes a leaked pending count fatal rather than merely slow, so keep the count honest on both sides of the pair: settle an immediate only once its clear has gone through, and drop the count again if the schedule threw. --- .../cache-components-scheduler.spec.ts | 61 ++++++++++++++++++- .../templates/cache-components-scheduler.ts | 40 ++++++++---- 2 files changed, 88 insertions(+), 13 deletions(-) diff --git a/packages/cloudflare/src/cli/templates/cache-components-scheduler.spec.ts b/packages/cloudflare/src/cli/templates/cache-components-scheduler.spec.ts index 6233505b8..7ca38f3ae 100644 --- a/packages/cloudflare/src/cli/templates/cache-components-scheduler.spec.ts +++ b/packages/cloudflare/src/cli/templates/cache-components-scheduler.spec.ts @@ -1,4 +1,4 @@ -import { afterAll, describe, expect, it } from "vitest"; +import { afterAll, describe, expect, it, vi } from "vitest"; import { runInSequentialTasks } from "./cache-components-scheduler.js"; @@ -159,4 +159,63 @@ describe("runInSequentialTasks", () => { expect(log).toEqual(["stage1"]); }); + + // A clear that threw did not cancel anything, so releasing the count there would advance the stage + // over an immediate that is still going to run. + it("keeps waiting on an immediate whose clear failed", async () => { + const failure = new Error("clear failed"); + globalThis.setImmediate = nativeSetImmediate; + globalThis.clearImmediate = (() => { + throw failure; + }) as unknown as typeof clearImmediate; + vi.resetModules(); + + try { + const { runInSequentialTasks: freshRunInSequentialTasks } = await import( + "./cache-components-scheduler.js" + ); + const log: string[] = []; + + await freshRunInSequentialTasks( + // Scheduling from a continuation puts the immediate behind the settle hop, so only the + // pending count can hold `stage1` back. + () => + void Promise.resolve().then(() => { + const immediate = setImmediate(() => log.push("still live")); + try { + clearImmediate(immediate); + } catch (error) { + log.push(error === failure ? "clear failed" : "unexpected error"); + } + }), + () => log.push("stage1") + ); + + expect(log).toEqual(["clear failed", "still live", "stage1"]); + } finally { + globalThis.setImmediate = nativeSetImmediate; + globalThis.clearImmediate = nativeClearImmediate; + } + }); + + // Resolving here would hand back a render whose last stage never flushed - the truncated response + // this scheduler exists to prevent, only silent. + it("rejects instead of advancing a stage that never settles", async () => { + let rescheduling = true; + + const settled = runInSequentialTasks( + () => { + const reschedule = () => { + if (rescheduling) setImmediate(reschedule); + }; + setImmediate(reschedule); + }, + () => { + throw new Error("unreachable: the stage must not be entered"); + } + ); + + await expect(settled).rejects.toThrow(/did not settle: 1 immediate\(s\) still pending/); + rescheduling = false; + }); }); diff --git a/packages/cloudflare/src/cli/templates/cache-components-scheduler.ts b/packages/cloudflare/src/cli/templates/cache-components-scheduler.ts index d64956fcb..2f0af043b 100644 --- a/packages/cloudflare/src/cli/templates/cache-components-scheduler.ts +++ b/packages/cloudflare/src/cli/templates/cache-components-scheduler.ts @@ -27,8 +27,9 @@ type ScheduleMacrotask = (callback: () => void) => unknown; const runStorage = new AsyncLocalStorage(); /** - * An immediate we never see settle - one cleared through a handle we did not hand out, or a render - * that never stops scheduling - must delay a stage rather than stall the pipeline for good. + * Bound on the tasks one stage waits for its own immediates. An immediate we never see settle - one + * cleared through a handle we did not hand out, or a render that never stops scheduling - fails the + * render, because advancing a stage over work it still owns is what truncates responses. */ const MAX_SETTLE_HOPS = 1000; @@ -56,7 +57,6 @@ function install(): ScheduleMacrotask { return previousSetImmediate(callback, ...args); } - run.pending++; let released = false; const release = () => { if (!released) { @@ -65,22 +65,30 @@ function install(): ScheduleMacrotask { } }; - const immediate = previousSetImmediate(() => { + run.pending++; + try { + const immediate = previousSetImmediate(() => { + release(); + callback(...args); + }); + if (typeof immediate === "object" && immediate !== null) { + releaseByImmediate.set(immediate, release); + } + return immediate; + } catch (error) { + // A schedule that threw left nothing behind to settle the count. release(); - callback(...args); - }); - if (typeof immediate === "object" && immediate !== null) { - releaseByImmediate.set(immediate, release); + throw error; } - return immediate; }; Object.defineProperty(countedSetImmediate, COUNTED, { value: true }); const countedClearImmediate = (immediate: unknown) => { + // A clear that threw left the immediate live, so it stays counted. + previousClearImmediate(immediate as Parameters[0]); if (typeof immediate === "object" && immediate !== null) { releaseByImmediate.get(immediate)?.(); } - return previousClearImmediate(immediate as Parameters[0]); }; globalThis.setImmediate = countedSetImmediate as unknown as typeof setImmediate; @@ -108,11 +116,19 @@ export function runInSequentialTasks(first: () => T, ...rest: Array<() => voi const settleThen = (next: () => void) => { hop(() => { - if (run.pending > 0 && hops++ < MAX_SETTLE_HOPS) { + if (run.pending === 0) { + next(); + return; + } + if (hops++ < MAX_SETTLE_HOPS) { settleThen(next); return; } - next(); + reject( + new Error( + `Cache Components render did not settle: ${run.pending} immediate(s) still pending after ${MAX_SETTLE_HOPS} tasks.` + ) + ); }); }; From f53c2a13619f252a0194a04d7b738a06a6b9caa3 Mon Sep 17 00:00:00 2001 From: Nathan Nguyen <146415969+NathanDrake2406@users.noreply.github.com> Date: Thu, 20 Aug 2026 19:41:38 +1000 Subject: [PATCH 12/14] fix(cache-components): settle staged renders on the request, not the run Next awaits the RSC payload before it starts staging, so React resumes much of the render from promises created outside `runInSequentialTasks`. Attributing immediates through an AsyncLocalStorage entered around the stage body never saw that work: the pending count read zero, every stage advanced over a flush still in flight, and the render slipped a stage - which reaches a runtime prefetch as a body holding only the partial marker. Next's own capture is process wide for exactly this reason. Charge immediates the run does not own to the Cloudflare request context, which wraps the whole render, and hold a stage until both counts settle. Requests still cannot gate each other's stages. Also trim the comments this PR added down to the repo's limit. --- .changeset/bright-caches-stream.md | 2 +- .../experimental/e2e/concurrent-rsc.test.ts | 10 +-- .../build/patches/plugins/cache-components.ts | 45 ++++------ .../cache-components-scheduler.spec.ts | 89 ++++++++++++++++++- .../templates/cache-components-scheduler.ts | 89 +++++++++++-------- 5 files changed, 159 insertions(+), 76 deletions(-) diff --git a/.changeset/bright-caches-stream.md b/.changeset/bright-caches-stream.md index 1bc0d4fe6..8d5118c07 100644 --- a/.changeset/bright-caches-stream.md +++ b/.changeset/bright-caches-stream.md @@ -4,6 +4,6 @@ fix: support Cache Components rendering on Workers -Next.js renders Cache Components as a pipeline of event loop tasks and, between two of them, drains the immediates React queued so each stage flushes before the next unblocks more content. Its Node.js implementation builds that boundary out of `_idleStart` timer alignment and `process.nextTick`, neither of which behaves the same on workerd, so the render lands a stage late: runtime prefetches drop everything that arrives after their final task aborts the render, and document renders report cached data as uncached and fail. Replace the staged runner with a workerd implementation that waits for a render's own immediates to settle before entering the next stage, and let Next.js resume partially prerendered routes instead of returning their cached shell as a complete response. +Next.js renders Cache Components as a pipeline of event loop tasks and, between two of them, drains the immediates React queued so each stage flushes before the next unblocks more content. Its Node.js implementation builds that boundary out of `_idleStart` timer alignment and `process.nextTick`, neither of which behaves the same on workerd, so the render lands a stage late: runtime prefetches drop everything that arrives after their final task aborts the render, and document renders report cached data as uncached and fail. Replace the staged runner with a workerd implementation that waits for the request's outstanding immediates to settle before entering the next stage, and let Next.js resume partially prerendered routes instead of returning their cached shell as a complete response. The wait is scoped to the request rather than to the render, because Next.js awaits the RSC payload before it starts staging, so React resumes much of the work from promises created outside the staged run. Also keep module loading `CacheSignal` instances and subscriptions request scoped. A shared promise registry forwards current and future imports through request-owned notifications, so overlapping renders wait without sharing timer handles. diff --git a/examples/e2e/experimental/e2e/concurrent-rsc.test.ts b/examples/e2e/experimental/e2e/concurrent-rsc.test.ts index 3b78bbe11..c8cbff7da 100644 --- a/examples/e2e/experimental/e2e/concurrent-rsc.test.ts +++ b/examples/e2e/experimental/e2e/concurrent-rsc.test.ts @@ -1,12 +1,10 @@ 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. + * Cache Components state Next keeps per process is shared by every request in a Worker isolate. When + * it holds request-bound I/O handles, an overlapping request clears a handle it does not own and + * workerd rejects it mid render, poisoning the isolate. Overlapping RSC prefetches are what a browser + * issues while hovering links, so they force that overlap cheaply. */ const ROUTE_PREFETCH = { rsc: "1", "next-router-prefetch": "1" }; 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 660fd35cd..f0d7bfe80 100644 --- a/packages/cloudflare/src/cli/build/patches/plugins/cache-components.ts +++ b/packages/cloudflare/src/cli/build/patches/plugins/cache-components.ts @@ -49,16 +49,12 @@ export const moduleLoadingSignalFileFilter = getCrossPlatformPathRegex( ); /** - * Next.js stages Cache Components renders across event loop tasks. Its Node.js implementation keeps - * the stages in one timer phase by mutating each timer's private `_idleStart` field, and drains the - * render's immediates between two stages through `process.nextTick`. Neither works on workerd: timer - * handles have no `_idleStart`, and `process.nextTick` is `queueMicrotask`, so the drain ends before - * React has scheduled its flush. Replace the staged runner with a workerd implementation and leave - * the now unreachable timer group as a fail-fast stub. + * Next stages Cache Components renders across event loop tasks, using `_idleStart` timer alignment + * and `process.nextTick` to bound them. Neither behaves the same on workerd, so swap the staged + * runner for a workerd implementation and stub the now unreachable timer group. * - * The matched code ships in every Next 16.2+ bundle, so only register the patches (and their - * fail-loudly errors) when the app actually enables Cache Components. Apps without the flag never - * execute these code paths and must not have their builds fail when Next reshapes the internals. + * The matched code ships in every Next 16.2+ bundle, so only register the patches - and their + * fail-loudly errors - for apps that actually enable Cache Components. */ export function patchCacheComponents( updater: ContentUpdater, @@ -74,8 +70,8 @@ export function patchCacheComponents( const schedulerPath = path.join(buildOpts.outputDir, "cloudflare-templates/cache-components-scheduler.js"); - // `ContentUpdater` skips a callback when the file filter or the content filter stops matching, so a - // renamed error message or a moved file would ship an unpatched build with no error at all. + // `ContentUpdater` silently skips a callback whose filter stops matching, so a renamed error + // message or moved file would otherwise ship an unpatched build. const applied = { scheduler: false, "module loading signal": false }; updater.updateContent("cache-components-scheduler", [ @@ -169,10 +165,8 @@ export function patchCacheComponentsScheduler(contents: string, runtimePath: str /** * 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. - * - * Only apps combining Cache Components with cache interception hit the unresumable shell, so apps - * without the flag must not depend on the shape of the generated middleware. + * so patch its output where Cloudflare takes ownership of the AWS build. Only apps combining Cache + * Components with cache interception hit the unresumable shell. */ export function patchMiddlewareCacheComponents(buildOpts: BuildOptions): void { if (buildOpts.config.dangerous?.enableCacheInterception !== true) { @@ -224,13 +218,10 @@ fix: |- `; /** - * Next.js subscribes every render's `CacheSignal` to one module-scoped signal. Both signals store timer - * cleanup closures, so a later request notifying an older subscriber can clear a handle owned by that - * older request. workerd rejects the cross-request I/O and the render returns a truncated Flight stream. - * - * Keep the signals and subscriptions request scoped. A shared registry retains plain import promises - * and resolves request-owned notification promises when new imports start. Each notification resumes - * in the subscriber's request before it touches that request's signal or timer handles. + * Next subscribes every render's `CacheSignal` to one module-scoped signal, and both store timer + * cleanup closures, so a later request can clear a handle owned by an older one. workerd rejects + * that cross-request I/O and the render truncates. Keep the signals and subscriptions request + * scoped, with a shared registry forwarding imports through request-owned notifications. */ export const requestScopedModuleLoadingSignalRule = ` rule: @@ -337,9 +328,8 @@ 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. + * Webpack emits the timer group and the sequential-task runner as separate modules. The runner is + * replaced above, so stub the unreachable timer group rather than ship its `_idleStart` mutation. */ export const disableAtomicTimerGroupRule = ` rule: @@ -363,9 +353,8 @@ fix: |- `; /** - * 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. + * The interceptor only has the cached PPR shell, not the postponed state Next needs to resume a + * Cache Components render, so these routes must reach Next's handler. Other ISR routes are unchanged. */ export const bypassPprCacheInterceptionRule = ` rule: diff --git a/packages/cloudflare/src/cli/templates/cache-components-scheduler.spec.ts b/packages/cloudflare/src/cli/templates/cache-components-scheduler.spec.ts index 7ca38f3ae..cfe5455d7 100644 --- a/packages/cloudflare/src/cli/templates/cache-components-scheduler.spec.ts +++ b/packages/cloudflare/src/cli/templates/cache-components-scheduler.spec.ts @@ -1,3 +1,5 @@ +import { AsyncLocalStorage } from "node:async_hooks"; + import { afterAll, describe, expect, it, vi } from "vitest"; import { runInSequentialTasks } from "./cache-components-scheduler.js"; @@ -5,15 +7,22 @@ import { runInSequentialTasks } from "./cache-components-scheduler.js"; const nativeSetImmediate = globalThis.setImmediate; const nativeClearImmediate = globalThis.clearImmediate; +/** Mirrors `init.ts`: an ALS store on a global symbol, wrapping the whole request. */ +const requestContextStorage = new AsyncLocalStorage(); +Object.defineProperty(globalThis, Symbol.for("__cloudflare-context__"), { + get: () => requestContextStorage.getStore(), + configurable: true, +}); +const withRequestContext = (run: () => T): T => requestContextStorage.run({}, run); + afterAll(() => { globalThis.setImmediate = nativeSetImmediate; globalThis.clearImmediate = nativeClearImmediate; }); /** - * Stands in for React Flight: a stage unblocks a gate, the component resumes across `hops` microtask - * hops, and only then schedules the flush through `setImmediate`. Next's contract is that both the - * work and its flush land before the next stage runs. + * Stands in for React Flight: a stage opens a gate, the component resumes `hops` microtasks later, + * then schedules its flush. Next's contract is that both land before the next stage. */ function createGatedRender(stages: number, hops: number, log: string[]) { const gates = Array.from({ length: stages }, () => { @@ -129,6 +138,80 @@ describe("runInSequentialTasks", () => { } }); + // Next awaits the RSC payload before staging, so React resumes from promises created outside the + // run. Counting by run alone reads zero and every stage advances over a flush still in flight. + it("waits for work rooted outside the staged run", async () => { + const log: string[] = []; + const gates = Array.from({ length: 3 }, () => { + let open!: () => void; + const opened = new Promise((resolve) => (open = resolve)); + return { opened, open }; + }); + + await withRequestContext(() => { + // Registered before the render, the way work rooted in the RSC payload is. + for (const [stage, gate] of gates.entries()) { + void gate.opened.then(async () => { + for (let hop = 0; hop < 4; hop++) await null; + setImmediate(() => { + log.push(`work${stage}`); + setImmediate(() => log.push(`flush${stage}`)); + }); + }); + } + + return runInSequentialTasks( + () => "rendered", + ...gates.map((gate, stage) => () => { + log.push(`stage${stage}`); + gate.open(); + }) + ); + }); + + expect(log).toEqual([ + "stage0", + "work0", + "flush0", + "stage1", + "work1", + "flush1", + "stage2", + "work2", + "flush2", + ]); + }); + + // Scoped to the request, not process wide like Next's capture, so requests stay independent. + it("keeps requests from gating each other", async () => { + const log: string[] = []; + + const noisy = withRequestContext(() => { + let remaining = 300; + const spin = () => { + if (remaining-- > 0) setImmediate(spin); + }; + return runInSequentialTasks( + () => setImmediate(spin), + () => log.push("noisy:stage0") + ); + }); + + const quiet = withRequestContext(() => { + let open!: () => void; + const opened = new Promise((resolve) => (open = resolve)); + void opened.then(() => setImmediate(() => log.push("quiet:flush"))); + return runInSequentialTasks( + () => open(), + () => log.push("quiet:stage0") + ); + }); + + await Promise.all([noisy, quiet]); + + expect(log.indexOf("quiet:flush")).toBeLessThan(log.indexOf("quiet:stage0")); + }); + it("rejects and skips the remaining tasks when a task throws", async () => { const order: string[] = []; const failure = new Error("stage failed"); diff --git a/packages/cloudflare/src/cli/templates/cache-components-scheduler.ts b/packages/cloudflare/src/cli/templates/cache-components-scheduler.ts index 2f0af043b..138e45ed2 100644 --- a/packages/cloudflare/src/cli/templates/cache-components-scheduler.ts +++ b/packages/cloudflare/src/cli/templates/cache-components-scheduler.ts @@ -1,46 +1,58 @@ /** * Cache Components staged rendering for workerd. * - * Next renders Cache Components as a pipeline of tasks and, between two of them, runs every - * immediate the previous task queued so React flushes that stage before the next one unblocks more - * content. On Node it gets that boundary from `process.nextTick`, which runs once the microtask - * queue is exhausted. workerd implements `process.nextTick` with `queueMicrotask`, so Next's drain - * gives up two microtask hops in - before React has scheduled its flush - and the render slips a - * stage behind. Runtime prefetches drop every chunk that arrives after their last task aborts the - * render, so the slip reaches the client as a prefetch body holding only the one byte partial - * marker, and a full render loses whatever the last stage produced. - * - * workerd runs timers and immediates from a single ordered macrotask queue and always drains - * microtasks between two of them, so scheduling an immediate is an exact "everything queued before - * me has run" signal. Count the immediates each staged render causes and hop until that count - * reaches zero before entering the next stage. Next's own fast-immediate capture is never engaged, - * which also keeps its process-wide capture slot free while requests overlap. + * Next drains the immediates React queued between two stages so each stage flushes before the next + * unblocks more content. It builds that boundary from `process.nextTick`, which workerd implements + * as `queueMicrotask`, so the drain ends before React has scheduled its flush and the render slips a + * stage. workerd runs timers and immediates from one ordered macrotask queue and drains microtasks + * between two of them, so an immediate is an exact "everything queued before me has run" signal: + * count the outstanding ones and hop until none remain. */ import { AsyncLocalStorage } from "node:async_hooks"; -type StagedRun = { pending: number }; +type StagedRun = { pending: number; scope: RequestScope }; + +/** Immediates the request caused that no staged run owns. Shared by the request's renders. */ +type RequestScope = { unattributed: number }; type ScheduleMacrotask = (callback: () => void) => unknown; -/** Attributes an immediate to the staged render that caused it, so one render never waits on another. */ +/** Keeps one render from waiting on another's immediates. */ const runStorage = new AsyncLocalStorage(); +const REQUEST_CONTEXT = Symbol.for("__cloudflare-context__"); + /** - * Bound on the tasks one stage waits for its own immediates. An immediate we never see settle - one - * cleared through a handle we did not hand out, or a render that never stops scheduling - fails the - * render, because advancing a stage over work it still owns is what truncates responses. + * Next awaits the RSC payload before it stages, so React resumes from promises created outside the + * run where `runStorage` cannot see it - which is why Next's own capture is process wide. The + * request is the next widest owner that still keeps one request from gating another. */ +const scopes = new WeakMap(); +const isolateScope: RequestScope = { unattributed: 0 }; + +function currentScope(): RequestScope { + const context = (globalThis as Record)[REQUEST_CONTEXT]; + if (typeof context !== "object" || context === null) { + return isolateScope; + } + + let scope = scopes.get(context); + if (!scope) { + scope = { unattributed: 0 }; + scopes.set(context, scope); + } + return scope; +} + +/** Fail rather than advance a stage over work it still owns, which is what truncates responses. */ const MAX_SETTLE_HOPS = 1000; const COUNTED = Symbol.for("__opennext.cache-components.countedSetImmediate"); let scheduleMacrotask: ScheduleMacrotask | undefined; -/** - * Count the immediates of the running staged render. Next patches `setImmediate` when its server - * environment loads, so wrap whatever is installed and keep delegating to it. - */ +/** Next patches `setImmediate` when its server environment loads, so wrap whatever is installed. */ function install(): ScheduleMacrotask { const current = globalThis.setImmediate as typeof setImmediate & { [COUNTED]?: true }; if (scheduleMacrotask && current[COUNTED]) { @@ -53,19 +65,22 @@ function install(): ScheduleMacrotask { const countedSetImmediate = (callback: (...args: unknown[]) => void, ...args: unknown[]) => { const run = runStorage.getStore(); - if (!run) { + // A render must also wait for work it did not root itself, so charge the rest to the request. + const owner = run ?? currentScope(); + if (owner === isolateScope) { return previousSetImmediate(callback, ...args); } let released = false; const release = () => { - if (!released) { - released = true; - run.pending--; - } + if (released) return; + released = true; + if (run) run.pending--; + else (owner as RequestScope).unattributed--; }; - run.pending++; + if (run) run.pending++; + else (owner as RequestScope).unattributed++; try { const immediate = previousSetImmediate(() => { release(); @@ -76,7 +91,7 @@ function install(): ScheduleMacrotask { } return immediate; } catch (error) { - // A schedule that threw left nothing behind to settle the count. + // Nothing was scheduled, so nothing will settle the count. release(); throw error; } @@ -101,13 +116,10 @@ function install(): ScheduleMacrotask { function ignore(): void {} -/** - * Drop-in replacement for Next's `runInSequentialTasks`: run `first`, then each of `rest` in its own - * task, and resolve with whatever `first` returned once the last task has settled. - */ +/** Drop-in for Next's `runInSequentialTasks`: each callback gets its own settled task. */ export function runInSequentialTasks(first: () => T, ...rest: Array<() => void>): Promise { const hop = install(); - const run: StagedRun = { pending: 0 }; + const run: StagedRun = { pending: 0, scope: currentScope() }; return new Promise((resolve, reject) => { let result: T; @@ -116,7 +128,8 @@ export function runInSequentialTasks(first: () => T, ...rest: Array<() => voi const settleThen = (next: () => void) => { hop(() => { - if (run.pending === 0) { + const pending = run.pending + run.scope.unattributed; + if (pending === 0) { next(); return; } @@ -126,7 +139,7 @@ export function runInSequentialTasks(first: () => T, ...rest: Array<() => voi } reject( new Error( - `Cache Components render did not settle: ${run.pending} immediate(s) still pending after ${MAX_SETTLE_HOPS} tasks.` + `Cache Components render did not settle: ${pending} immediate(s) still pending after ${MAX_SETTLE_HOPS} tasks.` ) ); }); @@ -137,7 +150,7 @@ export function runInSequentialTasks(first: () => T, ...rest: Array<() => voi runStorage.run(run, () => { if (stage === 0) { result = first(); - // A later task may reject this; the caller observes it through the returned promise. + // A later task may reject this; the caller sees it through the returned promise. const thenable = result as PromiseLike | null | undefined; if (thenable && typeof thenable.then === "function") { thenable.then(ignore, ignore); From 1786dfd129d5f6ed07a0c8042fbfe2e940dfbff4 Mon Sep 17 00:00:00 2001 From: Nathan Nguyen <146415969+NathanDrake2406@users.noreply.github.com> Date: Sat, 22 Aug 2026 00:49:38 +1000 Subject: [PATCH 13/14] fix(cache-components): preserve staged render boundaries React can capture setImmediate before the scheduler installs, and timer groups from one request can interleave. Both paths can advance a stage before queued Flight work flushes, which truncates runtime prefetches or hangs the request. Install the scheduler before Next evaluates, and serialize staged groups within each request. Keep separate requests independent and release the queue before adopting an asynchronous render result. Add a large dynamic PPR fixture and stress cold, warm, overlapping, prefetch, and client-navigation paths with cache interception enabled or disabled. --- .../experimental/e2e/concurrent-rsc.test.ts | 31 ++++- .../experimental/e2e/staged-render.test.ts | 66 ++++++++++ examples/e2e/experimental/open-next.config.ts | 11 +- .../src/app/large-shell/[slug]/page.tsx | 51 ++++++++ .../cloudflare/src/cli/build/bundle-server.ts | 18 ++- .../cache-components-scheduler.spec.ts | 118 +++++++++++++++++- .../templates/cache-components-scheduler.ts | 56 +++++++-- 7 files changed, 332 insertions(+), 19 deletions(-) create mode 100644 examples/e2e/experimental/src/app/large-shell/[slug]/page.tsx diff --git a/examples/e2e/experimental/e2e/concurrent-rsc.test.ts b/examples/e2e/experimental/e2e/concurrent-rsc.test.ts index c8cbff7da..82b2697bc 100644 --- a/examples/e2e/experimental/e2e/concurrent-rsc.test.ts +++ b/examples/e2e/experimental/e2e/concurrent-rsc.test.ts @@ -64,6 +64,12 @@ const ROUTES = [ resolvedHtml: "Imported module for second", resolvedRsc: "Imported module for second", }, + { + path: "/large-shell/concurrent", + shell: "Large shell", + resolvedHtml: "Large dynamic: ", + resolvedRsc: '"data-testid":"large-dynamic"', + }, ] as const; type Route = (typeof ROUTES)[number]; @@ -81,10 +87,33 @@ async function fetchPath( route: Route, kind: keyof typeof VARIANTS ): Promise { - const response = await request.get(route.path, { + let response = await request.get(route.path, { headers: VARIANTS[kind], maxRedirects: 0, }); + + // Next 16.3 redirects synthetic RSC requests for a fully static route to the same route with the + // cache-busting `_rsc` key that a browser normally supplies. Follow only that exact redirect shape, + // while keeping every other redirect visible as a failure below. + if (kind !== "document" && (response.status() === 307 || response.status() === 308)) { + const location = response.headers().location; + const redirected = location ? new URL(location, "http://opennext.invalid") : undefined; + if ( + !location?.startsWith("/") || + location.startsWith("//") || + redirected?.pathname !== route.path || + !redirected.searchParams.has("_rsc") + ) { + throw new Error(`${kind} ${route.path} returned an unexpected redirect to ${location ?? "nowhere"}`); + } + + await response.dispose(); + response = await request.get(`${redirected.pathname}${redirected.search}`, { + headers: VARIANTS[kind], + maxRedirects: 0, + }); + } + return { route, kind, diff --git a/examples/e2e/experimental/e2e/staged-render.test.ts b/examples/e2e/experimental/e2e/staged-render.test.ts index 01ef200f4..0a9bd1cb1 100644 --- a/examples/e2e/experimental/e2e/staged-render.test.ts +++ b/examples/e2e/experimental/e2e/staged-render.test.ts @@ -94,4 +94,70 @@ test.describe("staged Cache Components rendering", () => { expect(text, `${where} served another request's session`).toContain(sessions[index]!); } }); + + test("large runtime prefetches are complete across repeated and overlapping renders", async ({ + request, + }) => { + const sessions = Array.from({ length: 30 }, (_, index) => `large-${index}`); + const responses = []; + + for (const session of sessions.slice(0, 10)) { + responses.push(await runtimePrefetch(request, "/large-shell/one", session)); + } + responses.push( + ...(await Promise.all( + sessions.slice(10).map((session) => runtimePrefetch(request, "/large-shell/one", session)) + )) + ); + + for (const [index, response] of responses.entries()) { + const body = await response.body(); + const where = `large prefetch ${index}`; + + expectRuntimePrefetch(body, where); + expect(body.byteLength, `${where} was truncated`).toBeGreaterThan(60 * 1024); + const text = body.toString("utf8"); + expect(text, `${where} lost its final cached block`).toContain('"data-large-block":63'); + } + }); + + test("a cold large shell keeps request-time random values out of prerendering", async ({ request }) => { + const path = `/large-shell/cold-${Date.now()}`; + const session = `document-session-${Date.now()}`; + const document = await request.get(path, { headers: { "x-session": session } }); + const html = await document.text(); + + expect(document.status()).toEqual(200); + expect(html).toContain(""); + expect(html).toContain('data-large-block="63"'); + expect(html).toContain("Large dynamic: "); + expect(html).toContain(session); + + for (const headers of [ROUTE_PREFETCH, SEGMENT_PREFETCH]) { + const response = await request.get(path, { headers }); + const body = (await response.body()).toString("utf8"); + + expect(response.status(), `prefetch ${JSON.stringify(headers)} should complete`).toEqual(200); + expect(response.headers()["content-type"]).toContain("text/x-component"); + expect(body).toContain("0:{"); + expect(body).not.toMatch(/^\w+:E\{/m); + } + }); + + test("client navigation to a large dynamic shell does not reload the document", async ({ page }) => { + const session = `navigation-session-${Date.now()}`; + await page.setExtraHTTPHeaders({ "x-session": session }); + await page.goto("/large-shell/navigation-first"); + + await expect( + page.getByTestId("large-dynamic").filter({ hasText: `Large dynamic: navigation-first:${session}:` }) + ).toBeVisible(); + await page.getByRole("link", { name: "Large shell second item" }).click(); + await page.waitForURL("/large-shell/navigation-second"); + await expect( + page.getByTestId("large-dynamic").filter({ hasText: `Large dynamic: navigation-second:${session}:` }) + ).toBeVisible(); + await expect(page.locator('[data-large-block="63"]:visible')).toBeAttached(); + 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 c14e30462..c060f622e 100644 --- a/examples/e2e/experimental/open-next.config.ts +++ b/examples/e2e/experimental/open-next.config.ts @@ -1,11 +1,18 @@ import { defineCloudflareConfig } from "@opennextjs/cloudflare"; +import { withRegionalCache } from "@opennextjs/cloudflare/overrides/incremental-cache/regional-cache"; import r2IncrementalCache from "@opennextjs/cloudflare/overrides/incremental-cache/r2-incremental-cache"; import shardedTagCache from "@opennextjs/cloudflare/overrides/tag-cache/do-sharded-tag-cache"; import doQueue from "@opennextjs/cloudflare/overrides/queue/do-queue"; export default defineCloudflareConfig({ - incrementalCache: r2IncrementalCache, - enableCacheInterception: true, + incrementalCache: + process.env.OPEN_NEXT_REGIONAL_CACHE === "true" + ? withRegionalCache(r2IncrementalCache, { + mode: "long-lived", + shouldLazilyUpdateOnCacheHit: true, + }) + : r2IncrementalCache, + enableCacheInterception: process.env.OPEN_NEXT_CACHE_INTERCEPTION !== "false", // 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/large-shell/[slug]/page.tsx b/examples/e2e/experimental/src/app/large-shell/[slug]/page.tsx new file mode 100644 index 000000000..683a5eac0 --- /dev/null +++ b/examples/e2e/experimental/src/app/large-shell/[slug]/page.tsx @@ -0,0 +1,51 @@ +import { randomUUID } from "node:crypto"; + +import { headers } from "next/headers"; +import Link from "next/link"; +import { Suspense } from "react"; + +type PageProps = { params: Promise<{ slug: string }> }; + +export const unstable_instant = { + prefetch: "runtime", + samples: [{ params: { slug: "sample" }, headers: [["x-session", "sample"]] }], + unstable_disableBuildValidation: true, +}; + +const BLOCK = "cache-components-large-shell-".repeat(48); + +async function cachedBlock(index: number) { + "use cache"; + await Promise.resolve(); + return `${index}:${BLOCK}`; +} + +async function LargeBlock({ index }: { index: number }) { + for (let i = 0; i < index % 5; i++) await Promise.resolve(); + const value = await cachedBlock(index); + return

{value}

; +} + +async function RequestContent({ params }: PageProps) { + const [{ slug }, requestHeaders] = await Promise.all([params, headers()]); + return ( +

+ Large dynamic: {slug}:{requestHeaders.get("x-session") ?? "none"}:{randomUUID()} +

+ ); +} + +export default function LargeShellPage({ params }: PageProps) { + return ( +
+

Large shell

+ Large shell second item + {Array.from({ length: 64 }, (_, index) => ( + + ))} + Loading large dynamic content...

}> + +
+
+ ); +} diff --git a/packages/cloudflare/src/cli/build/bundle-server.ts b/packages/cloudflare/src/cli/build/bundle-server.ts index 6df204997..a16bbdf15 100644 --- a/packages/cloudflare/src/cli/build/bundle-server.ts +++ b/packages/cloudflare/src/cli/build/bundle-server.ts @@ -13,7 +13,11 @@ 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 { + cacheComponentsSchedulerModule, + patchCacheComponents, + usesCacheComponents, +} 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"; @@ -70,11 +74,21 @@ export async function bundleServer(buildOpts: BuildOptions, projectOpts: Project const packagePath = getPackagePath(buildOpts); const openNextServer = path.join(outputPath, packagePath, `index.mjs`); const openNextServerBundle = path.join(outputPath, packagePath, `handler.mjs`); + const initializeCacheComponentsScheduler = + usesCacheComponents(nextConfig) && !buildHelper.compareSemver(buildOpts.nextVersion, "<", "16.2.11"); const updater = new ContentUpdater(buildOpts); const result = await build({ - entryPoints: [openNextServer], + ...(initializeCacheComponentsScheduler + ? { + stdin: { + contents: `import "${cacheComponentsSchedulerModule}"; export { handler } from ${JSON.stringify(openNextServer)};`, + resolveDir: buildOpts.appPath, + sourcefile: "cache-components-server-entry.mjs", + }, + } + : { entryPoints: [openNextServer] }), bundle: true, outfile: openNextServerBundle, format: "esm", diff --git a/packages/cloudflare/src/cli/templates/cache-components-scheduler.spec.ts b/packages/cloudflare/src/cli/templates/cache-components-scheduler.spec.ts index cfe5455d7..459a82d49 100644 --- a/packages/cloudflare/src/cli/templates/cache-components-scheduler.spec.ts +++ b/packages/cloudflare/src/cli/templates/cache-components-scheduler.spec.ts @@ -1,12 +1,10 @@ import { AsyncLocalStorage } from "node:async_hooks"; +import { clearImmediate as nativeClearImmediate, setImmediate as nativeSetImmediate } from "node:timers"; import { afterAll, describe, expect, it, vi } from "vitest"; import { runInSequentialTasks } from "./cache-components-scheduler.js"; -const nativeSetImmediate = globalThis.setImmediate; -const nativeClearImmediate = globalThis.clearImmediate; - /** Mirrors `init.ts`: an ALS store on a global symbol, wrapping the whole request. */ const requestContextStorage = new AsyncLocalStorage(); Object.defineProperty(globalThis, Symbol.for("__cloudflare-context__"), { @@ -118,7 +116,10 @@ describe("runInSequentialTasks", () => { // The slow render keeps scheduling immediates long after the fast one is done, which must not // hold the fast render's stages back. - await Promise.all([runGatedRender(4, 12, slowLog), runGatedRender(4, 0, fastLog)]); + await Promise.all([ + withRequestContext(() => runGatedRender(4, 12, slowLog)), + withRequestContext(() => runGatedRender(4, 0, fastLog)), + ]); for (const log of [slowLog, fastLog]) { expect(log).toEqual([ @@ -138,6 +139,95 @@ describe("runInSequentialTasks", () => { } }); + it("runs staged renders from one request without interleaving their stages", async () => { + const log: string[] = []; + + await withRequestContext(() => + Promise.all([ + runInSequentialTasks( + () => log.push("first:a"), + () => log.push("second:a"), + () => log.push("third:a") + ), + runInSequentialTasks( + () => log.push("first:b"), + () => log.push("second:b"), + () => log.push("third:b") + ), + ]) + ); + + expect(log).toEqual(["first:a", "second:a", "third:a", "first:b", "second:b", "third:b"]); + }); + + it("preserves the async context of a staged render while it waits for an earlier render", async () => { + const workStorage = new AsyncLocalStorage(); + const log: string[] = []; + + await withRequestContext(() => + Promise.all([ + workStorage.run("a", () => + runInSequentialTasks( + () => log.push(`first:${workStorage.getStore()}`), + () => log.push(`second:${workStorage.getStore()}`) + ) + ), + workStorage.run("b", () => + runInSequentialTasks( + () => log.push(`first:${workStorage.getStore()}`), + () => log.push(`second:${workStorage.getStore()}`) + ) + ), + ]) + ); + + expect(log).toEqual(["first:a", "second:a", "first:b", "second:b"]); + }); + + it("starts the next staged render after an earlier render throws", async () => { + const failure = new Error("stage failed"); + const log: string[] = []; + + await withRequestContext(async () => { + const failed = runInSequentialTasks( + () => log.push("failed:first"), + () => { + throw failure; + } + ); + const recovered = runInSequentialTasks( + () => { + log.push("recovered:first"); + return "recovered"; + }, + () => log.push("recovered:second") + ); + + await expect(failed).rejects.toBe(failure); + await expect(recovered).resolves.toEqual("recovered"); + }); + + expect(log).toEqual(["failed:first", "recovered:first", "recovered:second"]); + }); + + it("releases the queue after the stages finish without awaiting the first result", async () => { + const log: string[] = []; + + await withRequestContext(async () => { + void runInSequentialTasks( + () => new Promise(() => {}), + () => log.push("pending:stage") + ); + + await runInSequentialTasks( + () => log.push("next:first"), + () => log.push("next:second") + ); + }); + + expect(log).toEqual(["pending:stage", "next:first", "next:second"]); + }); + // Next awaits the RSC payload before staging, so React resumes from promises created outside the // run. Counting by run alone reads zero and every stage advances over a flush still in flight. it("waits for work rooted outside the staged run", async () => { @@ -182,6 +272,26 @@ describe("runInSequentialTasks", () => { ]); }); + // React chooses and stores its scheduler when the runtime module loads. A wrapper installed on the + // first render cannot observe work sent through that earlier reference. + it("waits for work scheduled through an immediate reference captured during initialization", async () => { + const capturedSetImmediate = globalThis.setImmediate; + const log: string[] = []; + + await withRequestContext(() => + runInSequentialTasks( + () => { + void Promise.resolve().then(() => { + capturedSetImmediate(() => log.push("work")); + }); + }, + () => log.push("stage") + ) + ); + + expect(log).toEqual(["work", "stage"]); + }); + // Scoped to the request, not process wide like Next's capture, so requests stay independent. it("keeps requests from gating each other", async () => { const log: string[] = []; diff --git a/packages/cloudflare/src/cli/templates/cache-components-scheduler.ts b/packages/cloudflare/src/cli/templates/cache-components-scheduler.ts index 138e45ed2..a868f1c3a 100644 --- a/packages/cloudflare/src/cli/templates/cache-components-scheduler.ts +++ b/packages/cloudflare/src/cli/templates/cache-components-scheduler.ts @@ -14,7 +14,7 @@ import { AsyncLocalStorage } from "node:async_hooks"; type StagedRun = { pending: number; scope: RequestScope }; /** Immediates the request caused that no staged run owns. Shared by the request's renders. */ -type RequestScope = { unattributed: number }; +type RequestScope = { unattributed: number; stagedTail: Promise }; type ScheduleMacrotask = (callback: () => void) => unknown; @@ -23,13 +23,17 @@ const runStorage = new AsyncLocalStorage(); const REQUEST_CONTEXT = Symbol.for("__cloudflare-context__"); +function createRequestScope(): RequestScope { + return { unattributed: 0, stagedTail: Promise.resolve() }; +} + /** * Next awaits the RSC payload before it stages, so React resumes from promises created outside the * run where `runStorage` cannot see it - which is why Next's own capture is process wide. The * request is the next widest owner that still keeps one request from gating another. */ const scopes = new WeakMap(); -const isolateScope: RequestScope = { unattributed: 0 }; +const isolateScope = createRequestScope(); function currentScope(): RequestScope { const context = (globalThis as Record)[REQUEST_CONTEXT]; @@ -39,7 +43,7 @@ function currentScope(): RequestScope { let scope = scopes.get(context); if (!scope) { - scope = { unattributed: 0 }; + scope = createRequestScope(); scopes.set(context, scope); } return scope; @@ -116,18 +120,49 @@ function install(): ScheduleMacrotask { function ignore(): void {} +// React captures `setImmediate` while its runtime loads. Install before the Next server is evaluated +// so those captured references are counted too; installing only on the first staged render is late. +install(); + /** Drop-in for Next's `runInSequentialTasks`: each callback gets its own settled task. */ export function runInSequentialTasks(first: () => T, ...rest: Array<() => void>): Promise { const hop = install(); - const run: StagedRun = { pending: 0, scope: currentScope() }; + const scope = currentScope(); + const run: StagedRun = { pending: 0, scope }; + const previousRun = scope.stagedTail; + let releaseRun!: () => void; + scope.stagedTail = new Promise((resolve) => (releaseRun = resolve)); return new Promise((resolve, reject) => { let result: T; let stage = 0; let hops = 0; + let finished = false; + + const fail = (error: unknown) => { + if (finished) return; + finished = true; + releaseRun(); + reject(error); + }; + + const complete = () => { + if (finished) return; + finished = true; + releaseRun(); + resolve(result); + }; + + const schedule = (callback: () => void) => { + try { + hop(callback); + } catch (error) { + fail(error); + } + }; const settleThen = (next: () => void) => { - hop(() => { + schedule(() => { const pending = run.pending + run.scope.unattributed; if (pending === 0) { next(); @@ -137,7 +172,7 @@ export function runInSequentialTasks(first: () => T, ...rest: Array<() => voi settleThen(next); return; } - reject( + fail( new Error( `Cache Components render did not settle: ${pending} immediate(s) still pending after ${MAX_SETTLE_HOPS} tasks.` ) @@ -160,16 +195,17 @@ export function runInSequentialTasks(first: () => T, ...rest: Array<() => voi } }); } catch (error) { - reject(error); + fail(error); return; } stage++; hops = 0; - settleThen(stage > rest.length ? () => resolve(result) : enterStage); + settleThen(stage > rest.length ? complete : enterStage); }; - // Start from a fresh task, the way Next's first timer does. - hop(enterStage); + // Next schedules one timer group at a time. Queue groups from this request so their stages do + // not alternate, but do not make one request wait for another request's render. + void previousRun.then(() => schedule(enterStage)); }); } From 6b783939aefa8f9a19d4f002f68bc57c62879098 Mon Sep 17 00:00:00 2001 From: Nathan Nguyen <146415969+NathanDrake2406@users.noreply.github.com> Date: Tue, 25 Aug 2026 00:56:49 +1000 Subject: [PATCH 14/14] fix(cache-components): preserve promise-based immediates Next captures the custom promise hook from setImmediate when it initializes node:timers/promises. Workerd does not provide this hook on its global callback API, so promise-based immediates throw and PPR requests fail. Capture the native promise API before Next initializes. Expose a counted hook that preserves values, cancellation, and staged settlement. Add a hostile PPR fixture with split imports, wide streams, prefetch overlap, and client navigation coverage. --- .changeset/bright-caches-stream.md | 2 + .../e2e/hostile-cache-components.test.ts | 110 ++++++++++++++++++ .../app/hostile-shell/[slug]/chunk-loader.ts | 19 +++ .../hostile-shell/[slug]/chunks/chunk-00.ts | 1 + .../hostile-shell/[slug]/chunks/chunk-01.ts | 1 + .../hostile-shell/[slug]/chunks/chunk-02.ts | 1 + .../hostile-shell/[slug]/chunks/chunk-03.ts | 1 + .../hostile-shell/[slug]/chunks/chunk-04.ts | 1 + .../hostile-shell/[slug]/chunks/chunk-05.ts | 1 + .../hostile-shell/[slug]/chunks/chunk-06.ts | 1 + .../hostile-shell/[slug]/chunks/chunk-07.ts | 1 + .../hostile-shell/[slug]/chunks/chunk-08.ts | 1 + .../hostile-shell/[slug]/chunks/chunk-09.ts | 1 + .../hostile-shell/[slug]/chunks/chunk-10.ts | 1 + .../hostile-shell/[slug]/chunks/chunk-11.ts | 1 + .../src/app/hostile-shell/[slug]/page.tsx | 69 +++++++++++ .../app/hostile-shell/[slug]/timer-harness.ts | 63 ++++++++++ .../cache-components-scheduler.spec.ts | 84 +++++++++++++ .../templates/cache-components-scheduler.ts | 64 ++++++++-- 19 files changed, 413 insertions(+), 10 deletions(-) create mode 100644 examples/e2e/experimental/e2e/hostile-cache-components.test.ts create mode 100644 examples/e2e/experimental/src/app/hostile-shell/[slug]/chunk-loader.ts create mode 100644 examples/e2e/experimental/src/app/hostile-shell/[slug]/chunks/chunk-00.ts create mode 100644 examples/e2e/experimental/src/app/hostile-shell/[slug]/chunks/chunk-01.ts create mode 100644 examples/e2e/experimental/src/app/hostile-shell/[slug]/chunks/chunk-02.ts create mode 100644 examples/e2e/experimental/src/app/hostile-shell/[slug]/chunks/chunk-03.ts create mode 100644 examples/e2e/experimental/src/app/hostile-shell/[slug]/chunks/chunk-04.ts create mode 100644 examples/e2e/experimental/src/app/hostile-shell/[slug]/chunks/chunk-05.ts create mode 100644 examples/e2e/experimental/src/app/hostile-shell/[slug]/chunks/chunk-06.ts create mode 100644 examples/e2e/experimental/src/app/hostile-shell/[slug]/chunks/chunk-07.ts create mode 100644 examples/e2e/experimental/src/app/hostile-shell/[slug]/chunks/chunk-08.ts create mode 100644 examples/e2e/experimental/src/app/hostile-shell/[slug]/chunks/chunk-09.ts create mode 100644 examples/e2e/experimental/src/app/hostile-shell/[slug]/chunks/chunk-10.ts create mode 100644 examples/e2e/experimental/src/app/hostile-shell/[slug]/chunks/chunk-11.ts create mode 100644 examples/e2e/experimental/src/app/hostile-shell/[slug]/page.tsx create mode 100644 examples/e2e/experimental/src/app/hostile-shell/[slug]/timer-harness.ts diff --git a/.changeset/bright-caches-stream.md b/.changeset/bright-caches-stream.md index 8d5118c07..915378597 100644 --- a/.changeset/bright-caches-stream.md +++ b/.changeset/bright-caches-stream.md @@ -7,3 +7,5 @@ fix: support Cache Components rendering on Workers Next.js renders Cache Components as a pipeline of event loop tasks and, between two of them, drains the immediates React queued so each stage flushes before the next unblocks more content. Its Node.js implementation builds that boundary out of `_idleStart` timer alignment and `process.nextTick`, neither of which behaves the same on workerd, so the render lands a stage late: runtime prefetches drop everything that arrives after their final task aborts the render, and document renders report cached data as uncached and fail. Replace the staged runner with a workerd implementation that waits for the request's outstanding immediates to settle before entering the next stage, and let Next.js resume partially prerendered routes instead of returning their cached shell as a complete response. The wait is scoped to the request rather than to the render, because Next.js awaits the RSC payload before it starts staging, so React resumes much of the work from promises created outside the staged run. Also keep module loading `CacheSignal` instances and subscriptions request scoped. A shared promise registry forwards current and future imports through request-owned notifications, so overlapping renders wait without sharing timer handles. + +Preserve the custom promise API on the wrapped `setImmediate`. Workerd does not provide this hook on the global callback API, but Next.js requires it when it installs `node:timers/promises.setImmediate`. diff --git a/examples/e2e/experimental/e2e/hostile-cache-components.test.ts b/examples/e2e/experimental/e2e/hostile-cache-components.test.ts new file mode 100644 index 000000000..5ed34451c --- /dev/null +++ b/examples/e2e/experimental/e2e/hostile-cache-components.test.ts @@ -0,0 +1,110 @@ +import { expect, test, type APIRequestContext } from "@playwright/test"; + +const ROUTE_PREFETCH = { rsc: "1", "next-router-prefetch": "1" }; +const SEGMENT_PREFETCH = { ...ROUTE_PREFETCH, "next-router-segment-prefetch": "/_tree" }; +const RUNTIME_PREFETCH = { rsc: "1", "next-router-prefetch": "2" }; +const NAVIGATION = { rsc: "1", "next-url": "/" }; + +const PAYLOAD_CASES = [ + { kind: "document", headers: {} }, + { kind: "document", headers: {} }, + { kind: "route", headers: ROUTE_PREFETCH }, + { kind: "segment", headers: SEGMENT_PREFETCH }, + { kind: "runtime", headers: RUNTIME_PREFETCH }, + { kind: "navigation", headers: NAVIGATION }, +] as const; + +async function getBody(request: APIRequestContext, path: string, headers: Record = {}) { + const response = await request.get(path, { headers }); + return { + status: response.status(), + contentType: response.headers()["content-type"] ?? "", + body: await response.body(), + }; +} + +function expectCompleteShell(body: Buffer, where: string) { + expect(body.byteLength, `${where} should include the wide shell`).toBeGreaterThan(100 * 1024); + const text = body.toString("utf8"); + for (const index of [0, 47, 95]) { + expect(text, `${where} lost block ${index}`).toMatch( + new RegExp(`(?:"data-hostile-block":${index}|data-hostile-block="${index}")`) + ); + } + expect(text, `${where} returned a Flight error`).not.toMatch(/^\w+:E\{/m); +} + +function expectCompleteSegment(body: Buffer, where: string) { + const text = body.toString("utf8"); + expect(body.byteLength, `${where} carried only a close marker`).toBeGreaterThan(1); + expect(text, `${where} lost its root model`).toContain("0:{"); + expect(text, `${where} lost its router tree`).toContain('"tree"'); + expect(text, `${where} lost its build id`).toContain('"buildId"'); + expect(text, `${where} returned a Flight error`).not.toMatch(/^\w+:E\{/m); +} + +test.describe("hostile Cache Components graph", () => { + test("cold and warm documents, prefetches, and navigation payloads complete", async ({ request }) => { + const path = `/hostile-shell/cold-${Date.now()}`; + + for (const [index, { kind, headers }] of PAYLOAD_CASES.entries()) { + const session = `hostile-${Date.now()}-${index}`; + const result = await getBody(request, path, { ...headers, "x-session": session }); + const where = `${kind} ${path}`; + + expect(result.status, where).toEqual(200); + if (kind === "segment") { + expectCompleteSegment(result.body, where); + } else { + expectCompleteShell(result.body, where); + } + if (kind === "document") { + const html = result.body.toString("utf8"); + expect(result.contentType).toContain("text/html"); + expect(html).toContain(""); + expect(html).toContain("Hostile dynamic: "); + expect(html).toContain(path.split("/").at(-1)); + expect(html).toContain(session); + } else { + expect(result.contentType).toContain("text/x-component"); + } + } + }); + + test("same-route and cold-route runtime prefetches remain complete under overlap", async ({ request }) => { + const repeated = Array.from({ length: 24 }, (_, index) => + getBody(request, "/hostile-shell/repeated", { + ...RUNTIME_PREFETCH, + "x-session": `repeated-${index}`, + }) + ); + const cold = Array.from({ length: 24 }, (_, index) => + getBody(request, `/hostile-shell/cold-${Date.now()}-${index}`, RUNTIME_PREFETCH) + ); + + const results = await Promise.all([...repeated, ...cold]); + for (const [index, result] of results.entries()) { + expect(result.status, `overlapping runtime prefetch ${index}`).toEqual(200); + expect(result.contentType).toContain("text/x-component"); + expectCompleteShell(result.body, `overlapping runtime prefetch ${index}`); + } + }); + + test("client navigation resolves the request hole without a document reload", async ({ page }) => { + const session = `hostile-navigation-${Date.now()}`; + await page.setExtraHTTPHeaders({ "x-session": session }); + await page.goto("/hostile-shell/navigation-first"); + + await expect(page.getByTestId("hostile-dynamic")).toContainText( + `Hostile dynamic: navigation-first:${session}:` + ); + await expect(page.locator('[data-hostile-block="95"]:visible')).toBeVisible({ timeout: 15_000 }); + await page.getByRole("link", { name: "Hostile shell second item" }).click(); + await page.waitForURL("/hostile-shell/navigation-second"); + await expect(page.getByTestId("hostile-dynamic")).toContainText( + `Hostile dynamic: navigation-second:${session}:` + ); + await expect(page.locator('[data-hostile-block="95"]:visible')).toBeVisible({ timeout: 15_000 }); + expect(await page.evaluate(() => performance.getEntriesByType("navigation").length)).toEqual(1); + }); +}); diff --git a/examples/e2e/experimental/src/app/hostile-shell/[slug]/chunk-loader.ts b/examples/e2e/experimental/src/app/hostile-shell/[slug]/chunk-loader.ts new file mode 100644 index 000000000..efb1cfc4d --- /dev/null +++ b/examples/e2e/experimental/src/app/hostile-shell/[slug]/chunk-loader.ts @@ -0,0 +1,19 @@ +const chunkLoaders = [ + () => import("./chunks/chunk-00"), + () => import("./chunks/chunk-01"), + () => import("./chunks/chunk-02"), + () => import("./chunks/chunk-03"), + () => import("./chunks/chunk-04"), + () => import("./chunks/chunk-05"), + () => import("./chunks/chunk-06"), + () => import("./chunks/chunk-07"), + () => import("./chunks/chunk-08"), + () => import("./chunks/chunk-09"), + () => import("./chunks/chunk-10"), + () => import("./chunks/chunk-11"), +] as const; + +/** Keep imports genuinely lazy so a cold isolate must exercise Next's module-loading signal. */ +export async function loadHostileChunk(index: number) { + return chunkLoaders[index % chunkLoaders.length]!(); +} diff --git a/examples/e2e/experimental/src/app/hostile-shell/[slug]/chunks/chunk-00.ts b/examples/e2e/experimental/src/app/hostile-shell/[slug]/chunks/chunk-00.ts new file mode 100644 index 000000000..fe5fe7550 --- /dev/null +++ b/examples/e2e/experimental/src/app/hostile-shell/[slug]/chunks/chunk-00.ts @@ -0,0 +1 @@ +export const chunkToken = "hostile-chunk-00"; diff --git a/examples/e2e/experimental/src/app/hostile-shell/[slug]/chunks/chunk-01.ts b/examples/e2e/experimental/src/app/hostile-shell/[slug]/chunks/chunk-01.ts new file mode 100644 index 000000000..569ed821d --- /dev/null +++ b/examples/e2e/experimental/src/app/hostile-shell/[slug]/chunks/chunk-01.ts @@ -0,0 +1 @@ +export const chunkToken = "hostile-chunk-01"; diff --git a/examples/e2e/experimental/src/app/hostile-shell/[slug]/chunks/chunk-02.ts b/examples/e2e/experimental/src/app/hostile-shell/[slug]/chunks/chunk-02.ts new file mode 100644 index 000000000..7627ba587 --- /dev/null +++ b/examples/e2e/experimental/src/app/hostile-shell/[slug]/chunks/chunk-02.ts @@ -0,0 +1 @@ +export const chunkToken = "hostile-chunk-02"; diff --git a/examples/e2e/experimental/src/app/hostile-shell/[slug]/chunks/chunk-03.ts b/examples/e2e/experimental/src/app/hostile-shell/[slug]/chunks/chunk-03.ts new file mode 100644 index 000000000..51990dfdb --- /dev/null +++ b/examples/e2e/experimental/src/app/hostile-shell/[slug]/chunks/chunk-03.ts @@ -0,0 +1 @@ +export const chunkToken = "hostile-chunk-03"; diff --git a/examples/e2e/experimental/src/app/hostile-shell/[slug]/chunks/chunk-04.ts b/examples/e2e/experimental/src/app/hostile-shell/[slug]/chunks/chunk-04.ts new file mode 100644 index 000000000..67a05fe53 --- /dev/null +++ b/examples/e2e/experimental/src/app/hostile-shell/[slug]/chunks/chunk-04.ts @@ -0,0 +1 @@ +export const chunkToken = "hostile-chunk-04"; diff --git a/examples/e2e/experimental/src/app/hostile-shell/[slug]/chunks/chunk-05.ts b/examples/e2e/experimental/src/app/hostile-shell/[slug]/chunks/chunk-05.ts new file mode 100644 index 000000000..1ce5f0b6a --- /dev/null +++ b/examples/e2e/experimental/src/app/hostile-shell/[slug]/chunks/chunk-05.ts @@ -0,0 +1 @@ +export const chunkToken = "hostile-chunk-05"; diff --git a/examples/e2e/experimental/src/app/hostile-shell/[slug]/chunks/chunk-06.ts b/examples/e2e/experimental/src/app/hostile-shell/[slug]/chunks/chunk-06.ts new file mode 100644 index 000000000..bdc45d0f0 --- /dev/null +++ b/examples/e2e/experimental/src/app/hostile-shell/[slug]/chunks/chunk-06.ts @@ -0,0 +1 @@ +export const chunkToken = "hostile-chunk-06"; diff --git a/examples/e2e/experimental/src/app/hostile-shell/[slug]/chunks/chunk-07.ts b/examples/e2e/experimental/src/app/hostile-shell/[slug]/chunks/chunk-07.ts new file mode 100644 index 000000000..174e29b79 --- /dev/null +++ b/examples/e2e/experimental/src/app/hostile-shell/[slug]/chunks/chunk-07.ts @@ -0,0 +1 @@ +export const chunkToken = "hostile-chunk-07"; diff --git a/examples/e2e/experimental/src/app/hostile-shell/[slug]/chunks/chunk-08.ts b/examples/e2e/experimental/src/app/hostile-shell/[slug]/chunks/chunk-08.ts new file mode 100644 index 000000000..d7582c226 --- /dev/null +++ b/examples/e2e/experimental/src/app/hostile-shell/[slug]/chunks/chunk-08.ts @@ -0,0 +1 @@ +export const chunkToken = "hostile-chunk-08"; diff --git a/examples/e2e/experimental/src/app/hostile-shell/[slug]/chunks/chunk-09.ts b/examples/e2e/experimental/src/app/hostile-shell/[slug]/chunks/chunk-09.ts new file mode 100644 index 000000000..bb8879f00 --- /dev/null +++ b/examples/e2e/experimental/src/app/hostile-shell/[slug]/chunks/chunk-09.ts @@ -0,0 +1 @@ +export const chunkToken = "hostile-chunk-09"; diff --git a/examples/e2e/experimental/src/app/hostile-shell/[slug]/chunks/chunk-10.ts b/examples/e2e/experimental/src/app/hostile-shell/[slug]/chunks/chunk-10.ts new file mode 100644 index 000000000..26e4fd172 --- /dev/null +++ b/examples/e2e/experimental/src/app/hostile-shell/[slug]/chunks/chunk-10.ts @@ -0,0 +1 @@ +export const chunkToken = "hostile-chunk-10"; diff --git a/examples/e2e/experimental/src/app/hostile-shell/[slug]/chunks/chunk-11.ts b/examples/e2e/experimental/src/app/hostile-shell/[slug]/chunks/chunk-11.ts new file mode 100644 index 000000000..ae30d8524 --- /dev/null +++ b/examples/e2e/experimental/src/app/hostile-shell/[slug]/chunks/chunk-11.ts @@ -0,0 +1 @@ +export const chunkToken = "hostile-chunk-11"; diff --git a/examples/e2e/experimental/src/app/hostile-shell/[slug]/page.tsx b/examples/e2e/experimental/src/app/hostile-shell/[slug]/page.tsx new file mode 100644 index 000000000..1877c564c --- /dev/null +++ b/examples/e2e/experimental/src/app/hostile-shell/[slug]/page.tsx @@ -0,0 +1,69 @@ +import { randomUUID } from "node:crypto"; + +import { headers } from "next/headers"; +import Link from "next/link"; +import { Suspense } from "react"; + +import { loadHostileChunk } from "./chunk-loader"; +import { hostileYield } from "./timer-harness"; + +type PageProps = { params: Promise<{ slug: string }> }; + +export const unstable_instant = { + prefetch: "runtime", + samples: [{ params: { slug: "sample" }, headers: [["x-session", "sample"]] }], + unstable_disableBuildValidation: true, +}; + +const LAST_BLOCK = 95; +const BLOCK_PAYLOAD = "hostile-cache-components-payload-".repeat(36); + +async function getHostileBlock(index: number) { + "use cache"; + + const { chunkToken } = await loadHostileChunk(index); + await hostileYield(index); + return `${index}:${chunkToken}:${BLOCK_PAYLOAD}`; +} + +async function HostileBlock({ index }: { index: number }) { + for (let hop = 0; hop < index % 7; hop++) { + await Promise.resolve(); + } + + const value = await getHostileBlock(index); + return

{value}

; +} + +async function RequestHole({ params }: PageProps) { + const [{ slug }, requestHeaders] = await Promise.all([params, headers()]); + await hostileYield(slug.length); + + return ( +

+ Hostile dynamic: {slug}:{requestHeaders.get("x-session") ?? "none"}:{randomUUID()} +

+ ); +} + +/** + * A deliberately hostile Cache Components graph: a wide cached shell, cold split-chunk imports, + * every immediate API an application can capture, varied microtask depth, and a request-only hole. + * Truncation at any staged boundary loses a numbered block or the final sentinel. + */ +export default function HostileShellPage({ params }: PageProps) { + return ( +
+

Hostile shell

+ Hostile shell second item + {Array.from({ length: LAST_BLOCK + 1 }, (_, index) => ( + Loading block {index}

}> + +
+ ))} + Loading hostile dynamic...

}> + +
+
+ ); +} diff --git a/examples/e2e/experimental/src/app/hostile-shell/[slug]/timer-harness.ts b/examples/e2e/experimental/src/app/hostile-shell/[slug]/timer-harness.ts new file mode 100644 index 000000000..a805fac9f --- /dev/null +++ b/examples/e2e/experimental/src/app/hostile-shell/[slug]/timer-harness.ts @@ -0,0 +1,63 @@ +import { setImmediate as timersSetImmediate } from "node:timers"; +import { setImmediate as timersPromisesSetImmediate } from "node:timers/promises"; + +type ImmediateHandle = ReturnType; + +// Capture every scheduling surface when this application module initializes. Real applications and +// their dependencies commonly keep these references, so replacing only the current global function +// is not sufficient to control the work they schedule later. +const capturedGlobalSetImmediate = globalThis.setImmediate; +const capturedGlobalClearImmediate = globalThis.clearImmediate; +const capturedTimersSetImmediate = timersSetImmediate; +const capturedTimersPromisesSetImmediate = timersPromisesSetImmediate; +const capturedNextTick = process.nextTick.bind(process); + +function callbackImmediate(schedule: typeof setImmediate): Promise { + return new Promise((resolve) => schedule(resolve)); +} + +function nextTick(): Promise { + return new Promise((resolve) => capturedNextTick(resolve)); +} + +function nestedImmediate(): Promise { + return new Promise((resolve) => { + capturedGlobalSetImmediate(() => capturedTimersSetImmediate(resolve)); + }); +} + +function cancelledImmediate(): void { + const handle = capturedGlobalSetImmediate(() => { + throw new Error("A cancelled hostile-shell immediate ran"); + }) as ImmediateHandle; + capturedGlobalClearImmediate(handle); +} + +/** Exercise the scheduling shapes that can place React's flush after a staged-render boundary. */ +export async function hostileYield(index: number): Promise { + await Promise.resolve(); + + switch (index % 6) { + case 0: + await callbackImmediate(capturedGlobalSetImmediate); + break; + case 1: + await callbackImmediate(capturedTimersSetImmediate); + break; + case 2: + await capturedTimersPromisesSetImmediate(); + break; + case 3: + await nestedImmediate(); + break; + case 4: + cancelledImmediate(); + await callbackImmediate(capturedGlobalSetImmediate); + break; + default: + await nextTick(); + await callbackImmediate(capturedTimersSetImmediate); + } + + await new Promise((resolve) => queueMicrotask(resolve)); +} diff --git a/packages/cloudflare/src/cli/templates/cache-components-scheduler.spec.ts b/packages/cloudflare/src/cli/templates/cache-components-scheduler.spec.ts index 459a82d49..504919d24 100644 --- a/packages/cloudflare/src/cli/templates/cache-components-scheduler.spec.ts +++ b/packages/cloudflare/src/cli/templates/cache-components-scheduler.spec.ts @@ -1,5 +1,6 @@ import { AsyncLocalStorage } from "node:async_hooks"; import { clearImmediate as nativeClearImmediate, setImmediate as nativeSetImmediate } from "node:timers"; +import { promisify } from "node:util"; import { afterAll, describe, expect, it, vi } from "vitest"; @@ -292,6 +293,89 @@ describe("runInSequentialTasks", () => { expect(log).toEqual(["work", "stage"]); }); + it("preserves and waits for the promise-based immediate captured by Next", async () => { + type PromisifiedImmediate = (value?: T, options?: { signal?: AbortSignal }) => Promise; + const capturedSetImmediatePromise = ( + globalThis.setImmediate as typeof setImmediate & { + [promisify.custom]?: PromisifiedImmediate; + } + )[promisify.custom]; + const log: string[] = []; + + expect(capturedSetImmediatePromise).toBeTypeOf("function"); + await withRequestContext(() => + runInSequentialTasks( + () => { + void capturedSetImmediatePromise!("preserved").then((value) => log.push(value)); + }, + () => log.push("stage") + ) + ); + + expect(log).toEqual(["preserved", "stage"]); + }); + + it("releases a rejected promise-based immediate", async () => { + type PromisifiedImmediate = (value?: T, options?: { signal?: AbortSignal }) => Promise; + const capturedSetImmediatePromise = ( + globalThis.setImmediate as typeof setImmediate & { + [promisify.custom]?: PromisifiedImmediate; + } + )[promisify.custom]; + const abort = new AbortController(); + const log: string[] = []; + + await withRequestContext(() => + runInSequentialTasks( + () => { + const pending = capturedSetImmediatePromise!(undefined, { signal: abort.signal }); + void pending.catch(() => log.push("aborted")); + abort.abort(); + }, + () => log.push("stage") + ) + ); + + expect(log).toEqual(["aborted", "stage"]); + }); + + it("supplies the promise hook missing from workerd's global immediate", async () => { + const workerdSetImmediate = ((callback: (...args: unknown[]) => void, ...args: unknown[]) => + nativeSetImmediate(callback, ...args)) as typeof setImmediate; + globalThis.setImmediate = workerdSetImmediate; + globalThis.clearImmediate = nativeClearImmediate; + vi.resetModules(); + + try { + const { runInSequentialTasks: freshRunInSequentialTasks } = await import( + "./cache-components-scheduler.js" + ); + type PromisifiedImmediate = (value?: T) => Promise; + const capturedSetImmediatePromise = ( + globalThis.setImmediate as typeof setImmediate & { + [promisify.custom]?: PromisifiedImmediate; + } + )[promisify.custom]; + const log: string[] = []; + + expect(workerdSetImmediate[promisify.custom]).toBeUndefined(); + expect(capturedSetImmediatePromise).toBeTypeOf("function"); + await withRequestContext(() => + freshRunInSequentialTasks( + () => { + void capturedSetImmediatePromise!().then(() => log.push("work")); + }, + () => log.push("stage") + ) + ); + + expect(log).toEqual(["work", "stage"]); + } finally { + globalThis.setImmediate = nativeSetImmediate; + globalThis.clearImmediate = nativeClearImmediate; + } + }); + // Scoped to the request, not process wide like Next's capture, so requests stay independent. it("keeps requests from gating each other", async () => { const log: string[] = []; diff --git a/packages/cloudflare/src/cli/templates/cache-components-scheduler.ts b/packages/cloudflare/src/cli/templates/cache-components-scheduler.ts index a868f1c3a..7df0735bc 100644 --- a/packages/cloudflare/src/cli/templates/cache-components-scheduler.ts +++ b/packages/cloudflare/src/cli/templates/cache-components-scheduler.ts @@ -10,6 +10,8 @@ */ import { AsyncLocalStorage } from "node:async_hooks"; +import { setImmediate as timersPromisesSetImmediate } from "node:timers/promises"; +import { promisify } from "node:util"; type StagedRun = { pending: number; scope: RequestScope }; @@ -17,6 +19,10 @@ type StagedRun = { pending: number; scope: RequestScope }; type RequestScope = { unattributed: number; stagedTail: Promise }; type ScheduleMacrotask = (callback: () => void) => unknown; +type PromisifiedSetImmediate = ( + value?: T, + options?: { ref?: boolean; signal?: AbortSignal } +) => Promise; /** Keeps one render from waiting on another's immediates. */ const runStorage = new AsyncLocalStorage(); @@ -55,6 +61,9 @@ const MAX_SETTLE_HOPS = 1000; const COUNTED = Symbol.for("__opennext.cache-components.countedSetImmediate"); let scheduleMacrotask: ScheduleMacrotask | undefined; +// workerd's global callback API does not expose Node's custom-promisify hook. Capture its native +// promise API before Next replaces the `node:timers/promises` export, then expose that as the hook. +const scheduleMacrotaskPromisified = timersPromisesSetImmediate as PromisifiedSetImmediate; /** Next patches `setImmediate` when its server environment loads, so wrap whatever is installed. */ function install(): ScheduleMacrotask { @@ -66,42 +75,77 @@ function install(): ScheduleMacrotask { const previousSetImmediate = globalThis.setImmediate; const previousClearImmediate = globalThis.clearImmediate; const releaseByImmediate = new WeakMap void>(); + const previousPromisifiedSetImmediate = + (previousSetImmediate as typeof setImmediate & { [promisify.custom]?: PromisifiedSetImmediate })[ + promisify.custom + ] ?? scheduleMacrotaskPromisified; - const countedSetImmediate = (callback: (...args: unknown[]) => void, ...args: unknown[]) => { + const countCurrentWork = () => { const run = runStorage.getStore(); // A render must also wait for work it did not root itself, so charge the rest to the request. const owner = run ?? currentScope(); - if (owner === isolateScope) { - return previousSetImmediate(callback, ...args); - } + if (owner === isolateScope) return; let released = false; - const release = () => { + if (run) run.pending++; + else (owner as RequestScope).unattributed++; + + return () => { if (released) return; released = true; if (run) run.pending--; else (owner as RequestScope).unattributed--; }; + }; - if (run) run.pending++; - else (owner as RequestScope).unattributed++; + const countedSetImmediate = (callback: (...args: unknown[]) => void, ...args: unknown[]) => { + const release = countCurrentWork(); try { const immediate = previousSetImmediate(() => { - release(); + release?.(); callback(...args); }); - if (typeof immediate === "object" && immediate !== null) { + if (release && typeof immediate === "object" && immediate !== null) { releaseByImmediate.set(immediate, release); } return immediate; } catch (error) { // Nothing was scheduled, so nothing will settle the count. - release(); + release?.(); throw error; } }; Object.defineProperty(countedSetImmediate, COUNTED, { value: true }); + if (previousPromisifiedSetImmediate) { + const countedSetImmediatePromise: PromisifiedSetImmediate = ( + value?: T, + options?: { ref?: boolean; signal?: AbortSignal } + ) => { + const release = countCurrentWork(); + try { + const pending = previousPromisifiedSetImmediate(value, options); + if (!release) return pending; + return pending.then( + (result) => { + release(); + return result; + }, + (error: unknown) => { + release(); + throw error; + } + ); + } catch (error) { + release?.(); + throw error; + } + }; + Object.defineProperty(countedSetImmediate, promisify.custom, { + value: countedSetImmediatePromise, + }); + } + const countedClearImmediate = (immediate: unknown) => { // A clear that threw left the immediate live, so it stays counted. previousClearImmediate(immediate as Parameters[0]);