fix: support Cache Components on Workers - #1318
Conversation
🦋 Changeset detectedLatest commit: 69807b1 The changes in this PR will be included in the next version bump. This PR includes changesets to release 1 package
Not sure what this means? Click here to learn what changesets are. Click here if you're a maintainer who wants to add another changeset to this PR |
commit: |
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.
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.
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.
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.
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.
Cache interception alone made the generated middleware bundle's shape build-critical for apps that never render Cache Components routes.
457bcbd to
69807b1
Compare
|
Hi @NathanDrake2406! Could you give me an approximate timeline for when this is expected to be released? Thanks! |
|
Independent verification from a Next.js 16.3.1 / Cloudflare Workers app:
Full differential gate with the PR preview: concurrency 8, 100 requests per scenario, three scenarios (single 1 KiB boundary, single 64 KiB boundary, four parallel 64 KiB boundaries):
Every response was checked for HTTP 200, HTML content type, the static shell marker, every streamed boundary start/end marker, and minimum body length. This independently confirms that the scheduler patch in #1318 addresses the primary failure; payloads above 4 KiB are not required to reproduce it. |


What
Support Next.js Cache Components (
cacheComponents: true) on Workers:runInSequentialTasksRule), including the minified copies webpack and Turbopack emit, and fail the build loudly when an incompatible scheduler survives.bypassPprCacheInterceptionRule).CacheSignalto the request that owns its timer handles (requestScopedModuleLoadingSignalRule).Why
Observed on a real app running on this adapter (Next
16.3.0-canary.105, Turbopack build,cacheComponents: true). Without these patches it fails in three distinct ways on workerd:1. Staged renders rely on Node timer internals. Next forces its Cache Components render stages into one timer phase by mutating the private
_idleStartfield of Node timer handles and asserting"_idleStart" in timer. workerd handles have no such field, so Next disables its own scheduling patch at startup (Next.js cannot guarantee that Cache Components will run as expected), and workerd may interleave immediates between two stages that Next expects to run back to back, stalling the staged render.2. One request can destroy another request's response. Next keeps a single module-scoped
CacheSignalthat tracks in-flight dynamic imports (track-module-loading.instance.js). That signal storespendingTimeoutCleanup— a closure oversetImmediate/setTimeouthandles owned by whichever request scheduled them. A Worker isolate serves many overlapping requests against that one instance, so hovering links (which fires route + segment RSC prefetches) while another render was in flight produced:The throw escapes mid-render, so the victim response never completes — we observed HTTP 200 responses with empty gzip bodies, 10-byte responses, and truncated HTML/RSC payloads, followed by
failed to pipe responseandThe script will never generate a response. The broken state outlives the requests that caused it: subsequent sequential requests in the same isolate kept returning empty or truncated bodies until the isolate restarted. A single request in isolation always worked, which made this look like corruption rather than a concurrency bug.With the signal keyed on the per-request Cloudflare context, the same app survives 100 concurrent RSC prefetch/document requests over repeated rounds with every response complete, and sequential traffic afterwards stays healthy.
3. Cached PPR shells were served as finished pages. With cache interception enabled, the interceptor can only return the cached shell — it has no postponed state — so Cache Components routes returned shells with holes instead of resuming. These routes now bypass interception and reach Next's request handler.
How
All three fixes are
@ast-grep/napirules applied through theContentUpdaterbefore bundling, with build-time errors when a rule no longer matches, so upstream changes to Next's emitted code fail the build instead of silently shipping broken schedulers.The signal rule rewrites
getModuleLoadingSignal()to key the signal onglobalThis[Symbol.for("__cloudflare-context__")]— the per-request storerunWithCloudflareRequestContextalready establishes — falling back to the module-scoped instance for module loads outside a request (isolate startup).Testing
next@16.2.12and16.3.0-canary.105.examples/e2e/experimental/e2e/concurrent-rsc.test.ts): overlapping document/route-prefetch/segment-prefetch requests across PPR anduse cacheroutes over several rounds, a partial-prefetch → navigation-refetch sequence, and sequential follow-ups asserting the isolate still serves complete, untruncated responses. New/tracked-import/[slug]routes await a dynamic import inside a Suspense boundary sotrackPendingChunkLoadand the module loading signal are exercised in the bundle (verified via the patch marker in the built worker).pnpm code:checksand the affected vitest specs pass; the newconcurrent-rscsuite passes locally against a built worker. The browser-driven suites (ppr,use-cache) were not run to completion locally and are left to CI.Scope notes
Three things in the diff that are not the patches themselves, and why they are there:
enableCacheInterception: trueinexamples/e2e/experimental/open-next.config.ts. Fix 3 only exists on the interception path, so without this flag thebypassPprCacheInterceptionRulehas no e2e coverage at all. The experimental example is the only one that already setscacheComponents: true, which is the other half of the condition, so it is the only place the combination can be exercised. The example's other suites keep passing with interception on.Keying the signal on the Cloudflare context object. The rewrite stores
__openNextModuleLoadingSignalon the ALS store thatrunWithCloudflareRequestContextestablishes — the same objectgetCloudflareContext()returns. That object was picked because the requirement is precisely "same lifetime and ownership domain as the request that created the timer handles", and it is the only per-request store the adapter already has at that point in Next's module graph. Happy to move it behind a dedicated per-request slot intemplates/init.tsif you would rather not have a private field written onto a public-API object.Build errors rather than warnings, for an experimental feature.
patchCacheComponentsandpatchMiddlewareCacheComponentsare both gated on the resolved Next config, so only apps that enable Cache Components can reach these throws. For those apps a silently unpatched scheduler is a hung render or a truncated response in production, which is strictly worse than a failed build. Apps without the flag never register the matchers and never inspect the middleware bundle, so upstream reshaping of these internals cannot fail their builds.Next.js / React versions are unchanged. This branch previously bumped the
e2ecatalog tonext@16.2.12andreact@19.2.8; that is now split out and not part of this PR.next@16.2.11— the version onmain— already contains thecreateAtomicTimerGroupscheduler and theCannot schedule more timers into a group that already executedstring the rules match, so the patches are fully exercised without the bump. The checked-in fixtures are captured from16.2.12and16.3.0-canary.105deliberately, to show the matchers hold across more than the one version the repo pins.