Skip to content

Add Next.js 16.2.x compatibility patches (perf_hooks shim, fast-set-immediate shim, Turbopack handler delegation)#1262

Closed
chanceaclark wants to merge 4 commits into
opennextjs:mainfrom
chanceaclark:fix/next-16.2-compat
Closed

Add Next.js 16.2.x compatibility patches (perf_hooks shim, fast-set-immediate shim, Turbopack handler delegation)#1262
chanceaclark wants to merge 4 commits into
opennextjs:mainfrom
chanceaclark:fix/next-16.2-compat

Conversation

@chanceaclark
Copy link
Copy Markdown

@chanceaclark chanceaclark commented May 11, 2026

What

Three build-time patches that make Next.js 16.2.x apps work on Cloudflare Workers. Without these, every app-router request crashes on cold start.

1. perf_hooks shim (f61f7c4)

Next 16.2.x bundles gc-observer.js (imports PerformanceObserver from perf_hooks) and @vercel/otel (uses require("perf_hooks").performance) into the instrumentation hook loading path. esbuild normalizes bare perf_hooks to node:perf_hooks on the node platform, and Workers does not expose this module even with nodejs_compat.

A new shim re-exports globalThis.performance and provides a no-op PerformanceObserver. Resolved via a new shimNodeModules onResolve plugin (not alias) so the shim is bundled instead of being externalized by platform: "node".

2. fast-set-immediate shim (b42ef6d)

Next 16.2's app-render-scheduling imports next/dist/server/node-environment-extensions/fast-set-immediate.external, whose top-level install() runs:

const nodeTimers = require('node:timers');
globalThis.setImmediate = nodeTimers.setImmediate = patchedSetImmediate;

Under nodejs_compat the node:timers namespace is a frozen ESM module, so nodeTimers.setImmediate = ... throws Cannot assign to read only property 'setImmediate' of object '[object Module]' on every request, before any user code runs.

The fast-immediate scheduler is an optimization, not a correctness requirement. A new shim exports the same names (DANGEROUSLY_runPendingImmediatesAfterCurrentTask, expectNoPendingImmediates, unpatchedSetImmediate) as no-ops and surfaces globalThis.setImmediate directly.

This commit also extends the bundle banner to expose AsyncLocalStorage on globalThis so Next's async-local-storage.js picks up the real implementation rather than falling back to FakeAsyncLocalStorage (which throws on .run()/.exit() and cascades into a confusing Cannot read properties of undefined (reading 'require') when Turbopack loads app-page-turbo.runtime.prod.js).

3. patchPageExports plugin (39ab69b)

Next 16.2.x Turbopack app-page templates set up exports on a child module via esmExport(bindings, childId), then re-call esmExport for the entry module's own id. Each page chunk then does:

R.m(<entryId>)
module.exports = R.m(<entryId>).exports

R.m(<entryId>).exports only carries the second call's vendored bindings — handler lives on the child module. At request time Next reads components.ComponentMod.handler, gets undefined, and crashes with ComponentMod.handler is not a function on every app route.

The fix:

  1. Scan SSR app-page template chunks under .next/server/chunks/ssr/ to build an entry→child module-id map by anchoring on module.exports = [<entryId> and a.s([…,"handler",…], <childId>).
  2. Add an esbuild onLoad plugin that matches .next/server/app/**/page.js and .next/server/app/**/route.js. When the entry id has a recorded child, replace the terminator with a merge expression that copies the entry's own-property descriptors first, then layers the child's descriptors for keys the entry doesn't define. The merged object exposes handler alongside the Turbopack bindings while preserving lazy getters.

The patch only touches page.js / route.js. It does not modify the Turbopack runtime, esmExport, or instantiateModule, so it cannot regress instrumentation hook loading or app-page-turbo external loading.

Why

Without these three patches, Next.js 16.2.x on Cloudflare Workers fails on every request:

  • Cold start crashes at gc-observer.js evaluation (perf_hooks)
  • First request crashes at fast-set-immediate's install() (node:timers mutation)
  • Every app-router request crashes at ComponentMod.handler is not a function

Together they make a real Next 16.2.6 app-router storefront return 200s across homepage, search, category, product, brand, and blog routes.

Testing

Verified locally with a Next.js 16.2.6 app-router site running under [email protected] dev. Without the patches: 500 on every request. With the patches: 200 across the homepage, search, compare, category, product, brand, and blog routes.

tsc --noEmit and eslint are clean.

Notes

Next.js 16.2.x bundles gc-observer.js (which imports PerformanceObserver
from perf_hooks) and @vercel/otel (which uses
require("perf_hooks").performance) into the instrumentation hook loading
path.

esbuild normalises bare "perf_hooks" to "node:perf_hooks" on the node
platform, and Cloudflare Workers does not expose this module even with
nodejs_compat.

Add a perf-hooks shim that:
- re-exports performance from globalThis.performance (available in Workers)
- provides a no-op PerformanceObserver (GC observation is Node.js-specific)

Resolve "perf_hooks" and "node:perf_hooks" to the shim via a new
shim-node-modules onResolve plugin (not esbuild's alias config) so the
shim is bundled rather than externalized by platform:"node".

Related: opennextjs#1258
Next.js 16.2's app-render-scheduling imports
`next/dist/server/node-environment-extensions/fast-set-immediate.external`,
whose top-level install() runs `nodeTimers.setImmediate = patchedSetImmediate`
to install a custom immediate scheduler. Under `nodejs_compat`, the
`node:timers` module namespace is a frozen ESM module, so the assignment
throws "Cannot assign to read only property 'setImmediate' of object
'[object Module]'" on the first request, crashing every page render.

The fast-immediate scheduler is an optimization, not a correctness
requirement. Replace the module with a shim that exports the same names
(DANGEROUSLY_runPendingImmediatesAfterCurrentTask, expectNoPendingImmediates,
unpatchedSetImmediate) as no-ops and surfaces globalThis.setImmediate
directly. Pages render correctly without the patched scheduling in Workers.

Also expose AsyncLocalStorage on globalThis in the banner so Next's
async-local-storage.js detects the real implementation rather than falling
back to the throwing FakeAsyncLocalStorage shim — the fallback cascades
into a confusing "Cannot read properties of undefined (reading 'require')"
when Turbopack loads app-page-turbo.runtime.prod.js as an external module.

Related: opennextjs#1258
@changeset-bot
Copy link
Copy Markdown

changeset-bot Bot commented May 11, 2026

🦋 Changeset detected

Latest commit: 17ee18e

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

Next.js 16.2.x Turbopack app-page templates set up exports on a child
module via `esmExport(bindings, childId)`, then re-call esmExport for
the entry module's own id. Each page chunk then does:

  R.m(<entryId>)
  module.exports = R.m(<entryId>).exports

`R.m(<entryId>).exports` only carries the vendored bindings from the
second esmExport call — `handler` lives on the child module. At request
time Next reads `components.ComponentMod.handler` and gets undefined,
crashing with "ComponentMod.handler is not a function" on every app route.

Fix at build time:

1. Scan the SSR `app-page` template chunks under `.next/server/chunks/ssr/`
   to build an entry->child module-id map by reading the
   `module.exports = [<entryId>` and `a.s([...,"handler",...], <childId>)`
   anchors.

2. Add an esbuild onLoad plugin that matches each `.next/server/app/.../page.js`
   and `.next/server/app/.../route.js`. When the entry id has a recorded
   child, replace the terminator with a merge expression: copy the entry's
   own-property descriptors first, then layer the child's descriptors on top
   for keys the entry doesn't define. The merged object exposes `handler`
   alongside the Turbopack bindings while preserving lazy getters.

The patch only touches page.js / route.js — it does not modify the
Turbopack runtime, `esmExport`, or `instantiateModule`, so it can't
regress instrumentation hook loading or app-page-turbo external loading.

Related: opennextjs#1258
@chanceaclark chanceaclark force-pushed the fix/next-16.2-compat branch from 76f3e2c to 17ee18e Compare May 11, 2026 23:14
@chanceaclark chanceaclark marked this pull request as ready for review May 11, 2026 23:16
Copy link
Copy Markdown

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

Choose a reason for hiding this comment

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

✅ Devin Review: No Issues Found

Devin Review analyzed this PR and found no potential bugs to report.

View in Devin Review to see 5 additional findings.

Open in Devin Review

@vicb
Copy link
Copy Markdown
Contributor

vicb commented May 12, 2026

Thanks for the PR @chanceaclark

  • Happy to split into three separate PRs if that's preferable for review.

That would really be helpful please - please include a repro of the failure (link to a GH repo with instruction) for each of them.

Some comments/thoughts about your points above:

  1. perf_hooks

esbuild normalizes bare perf_hooks to node:perf_hooks on the node platform, and Workers does not expose this module even with nodejs_compat

perf_hooks is implemented natively in workerd if the compatibility date is on or after 2026-03-17

  1. Cannot assign to read only property 'setImmediate' of object '[object Module]'

There used to be a bug in workerd. It has been fixed months ago. Here again a recent compatibility date might be enough to fix the issue.

So before splitting this PR into multiple issues, it would be nice to try and bump the compatibility_date to something recent i.e. 2026-04-15 and check if it is enough to fix at least 1 and 2.

We have recently added a build time warning for older compat date. We should probably turn this into a build error.

This might also explain why I was not able to repro #1258.

@chanceaclark
Copy link
Copy Markdown
Author

  1. and 2. were resolved by bumping the compatibility date like you said 👨‍🍳 💋

I could only reproduce 3. on older versions of wrangler, I assume maybe it's because of previous module resolution bundling happening 🤷‍♂️ Didn't dig too deep, but upgrading wrangler to the latest solved it.

Closing this 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.

2 participants