Skip to content

fix: support Cache Components on Workers - #1318

Open
NathanDrake2406 wants to merge 6 commits into
opennextjs:mainfrom
NathanDrake2406:nathan/cache-components-workers
Open

fix: support Cache Components on Workers#1318
NathanDrake2406 wants to merge 6 commits into
opennextjs:mainfrom
NathanDrake2406:nathan/cache-components-workers

Conversation

@NathanDrake2406

@NathanDrake2406 NathanDrake2406 commented Aug 1, 2026

Copy link
Copy Markdown
Contributor

What

Support Next.js Cache Components (cacheComponents: true) on Workers:

  • Replace the staged render scheduler with a workerd-compatible one (runInSequentialTasksRule), including the minified copies webpack and Turbopack emit, and fail the build loudly when an incompatible scheduler survives.
  • Let Next.js resume partially prerendered routes instead of serving their cached PPR shell as a complete response (bypassPprCacheInterceptionRule).
  • Scope the module loading CacheSignal to 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 _idleStart field 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 CacheSignal that tracks in-flight dynamic imports (track-module-loading.instance.js). That signal stores pendingTimeoutCleanup — a closure over setImmediate/setTimeout handles 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:

Error: Cannot perform I/O on behalf of a different request.
    at patchedClearImmediate
    at CacheSignal.pendingTimeoutCleanup
    at CacheSignal.beginRead
    at CacheSignal.trackRead
    at trackPendingImport

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 response and The 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/napi rules applied through the ContentUpdater before 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 on globalThis[Symbol.for("__cloudflare-context__")] — the per-request store runWithCloudflareRequestContext already establishes — falling back to the module-scoped instance for module loads outside a request (isolate startup).

Testing

  • Unit specs for every rule, including fixtures of the minified Turbopack schedulers from next@16.2.12 and 16.3.0-canary.105.
  • New Playwright regression suite (examples/e2e/experimental/e2e/concurrent-rsc.test.ts): overlapping document/route-prefetch/segment-prefetch requests across PPR and use cache routes 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 so trackPendingChunkLoad and the module loading signal are exercised in the bundle (verified via the patch marker in the built worker).
  • Caveat: the original crash is timing-dependent (it needs a pending cleanup handle on the shared signal at the moment another request begins a read), so the e2e suite exercises the patched path and guards against regressions in the isolate's health rather than deterministically reproducing the pre-fix crash.
  • pnpm code:checks and the affected vitest specs pass; the new concurrent-rsc suite 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: true in examples/e2e/experimental/open-next.config.ts. Fix 3 only exists on the interception path, so without this flag the bypassPprCacheInterceptionRule has no e2e coverage at all. The experimental example is the only one that already sets cacheComponents: 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 __openNextModuleLoadingSignal on the ALS store that runWithCloudflareRequestContext establishes — the same object getCloudflareContext() 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 in templates/init.ts if you would rather not have a private field written onto a public-API object.

Build errors rather than warnings, for an experimental feature. patchCacheComponents and patchMiddlewareCacheComponents are 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 e2e catalog to next@16.2.12 and react@19.2.8; that is now split out and not part of this PR. next@16.2.11 — the version on main — already contains the createAtomicTimerGroup scheduler and the Cannot schedule more timers into a group that already executed string the rules match, so the patches are fully exercised without the bump. The checked-in fixtures are captured from 16.2.12 and 16.3.0-canary.105 deliberately, to show the matchers hold across more than the one version the repo pins.

@changeset-bot

changeset-bot Bot commented Aug 1, 2026

Copy link
Copy Markdown

🦋 Changeset detected

Latest commit: 69807b1

The changes in this PR will be included in the next version bump.

This PR includes changesets to release 1 package
Name Type
@opennextjs/cloudflare Patch

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

@pkg-pr-new

pkg-pr-new Bot commented Aug 1, 2026

Copy link
Copy Markdown

Open in StackBlitz

npm i https://pkg.pr.new/@opennextjs/cloudflare@1318

commit: 69807b1

@devin-ai-integration devin-ai-integration Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Devin Review found 1 potential issue.

View 2 additional findings in Devin Review.

Open in Devin Review

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.
@NathanDrake2406
NathanDrake2406 force-pushed the nathan/cache-components-workers branch from 457bcbd to 69807b1 Compare August 3, 2026 06:10
@PavelProdan

Copy link
Copy Markdown

Hi @NathanDrake2406! Could you give me an approximate timeline for when this is expected to be released? Thanks!

@Denis-Athletix

Copy link
Copy Markdown

Independent verification from a Next.js 16.3.1 / Cloudflare Workers app:

  • Stable @opennextjs/cloudflare@1.20.2 fails deterministically even with one 1 KiB request-bound Suspense boundary: HTTP 500, workerd reports that the Worker will never generate a response, and Next logs the setTimeout() compatibility warning.
  • next@16.3.1-canary.22 with OpenNext 1.20.2 still fails.
  • Wrangler 4.123.0 with stable Next/OpenNext still fails.
  • The immutable PR fix: support Cache Components on Workers #1318 preview (https://pkg.pr.new/@opennextjs/cloudflare@69807b1) passes the same fixture.

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):

Mode Complete responses Failures Worker failure signatures
Cache Components off 300/300 0 0
Cache Components on 300/300 0 0

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.

@kmsomebody

Copy link
Copy Markdown

I'm getting inconsistent behavior with this PR.
Prefetch requests will most of the time have a response payload of only one byte.

You can see multiple page refreshes in the attached screenshot. Of 7 page loads, the browse route was only successfully prefetched 2 times. The route becomes blocking in case the prefetch response is empty.
image

@NathanDrake2406

Copy link
Copy Markdown
Contributor Author

I'm getting inconsistent behavior with this PR. Prefetch requests will most of the time have a response payload of only one byte.

You can see multiple page refreshes in the attached screenshot. Of 7 page loads, the browse route was only successfully prefetched 2 times. The route becomes blocking in case the prefetch response is empty. image

Seems like the feature is not getting supported anytime soon. But I'll have a crack at it again when I'm free will update the PR

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants