Skip to content

fix: guard require.extensions for Node 24.15+ under Yarn PnP#93248

Open
bmorros94 wants to merge 4 commits intovercel:canaryfrom
bmorros94:fix/node24-require-extensions
Open

fix: guard require.extensions for Node 24.15+ under Yarn PnP#93248
bmorros94 wants to merge 4 commits intovercel:canaryfrom
bmorros94:fix/node24-require-extensions

Conversation

@bmorros94
Copy link
Copy Markdown

@bmorros94 bmorros94 commented Apr 25, 2026

Summary

  • Guard all require.extensions accesses in packages/next/src/build/next-config-ts/require-hook.ts against undefined
  • On Node 24.15+, the require function passed to CJS modules loaded via the ESM loader no longer has .extensions when the module source comes through a custom loader (Yarn PnP's zip loader), causing next build to crash with a TypeError at module load time
  • Uses optional chaining (?.) on the top-level read and early-return guards in registerHook/deregisterHook so the file loads safely when require.extensions is undefined
  • No behavior change on Node versions where require.extensions is defined

Fixes #92935

Test plan

  • New unit test packages/next/src/build/next-config-ts/require-hook.test.ts covering:
    • Module-level optional chain (oldJSHook = require.extensions?.['.js']) does not throw when require.extensions is undefined
    • registerHook(swcOptions) returns without throwing on the undefined path (Node 24.15+ Yarn PnP simulation via Module._extensions = undefined + jest.isolateModules)
    • deregisterHook() returns without throwing on the undefined path
    • registerHook / deregisterHook continue to return without throwing on the standard Node 20/22 / non-PnP path
  • Test passes locally (pnpm jest packages/next/src/build/next-config-ts/require-hook.test.ts)
  • Verify next build works on Node 24.15+ with Yarn PnP (the crash scenario from the issue)
  • Verify next build still works on Node 20/22 with Yarn PnP (no regression)

On Node 24.15+, the `require` function passed to CJS modules loaded
via the ESM loader no longer has `.extensions` defined when the module
source comes through a custom loader (Yarn PnP's zip loader). This
causes `next build` to crash with a TypeError at module load time.

Add optional chaining on the top-level read and early-return guards
in registerHook/deregisterHook so the file loads safely when
require.extensions is undefined. No behavior change on Node versions
where require.extensions is defined.

Fixes vercel#92935
Add a unit test that simulates the Node 24.15+ Yarn PnP environment by
clearing Module._extensions before the hook module is loaded. The fresh
require created inside `jest.isolateModules` then sees `require.extensions`
as undefined, exercising the new guards in `registerHook` and
`deregisterHook`. The previously crashing module-level optional chain
(`oldJSHook = require.extensions?.['.js']`) is also covered.

A second describe block covers the standard Node 20/22 / non-PnP path:
both functions must continue to return without throwing. We do not assert
on which handlers got registered because Jest gives each isolated require
its own `extensions` object distinct from `Module._extensions`, so we
cannot inspect them from the test side; the actual handler wiring is
exercised by the existing next-config-ts integration tests.
@bmorros94 bmorros94 force-pushed the fix/node24-require-extensions branch from 736ca6b to bb65567 Compare April 28, 2026 00:52
…ce tautological tests with real subprocess regression coverage

require-hook.ts:
- Add a `warnOnce` inside the `if (!require.extensions)` early-return so
  users with `require('./helper.ts')` in next.config.ts on Node 24.15+
  Yarn PnP get an actionable diagnostic instead of a downstream
  cryptic `TypeError`.
- Hoist the `oldJSHook` undefined check into the same early-return and
  drop the `oldJSHook!` non-null assertions. TypeScript can now narrow
  oldJSHook to a callable inside the closures, removing the runtime
  hazard of calling an undefined hook if the env transitioned.

require-hook.test.ts:
- The previous `Module._extensions = undefined` + `jest.isolateModules`
  simulation does not exercise the regression path. Jest's
  `_createRequireImplementation` unconditionally sets
  `moduleRequire.extensions = Object.create(null)` (a truthy empty
  object), so the SUT always sees a defined extensions object regardless
  of the outer process state. The "Node 24.15+ Yarn PnP" tests passed
  on canary too — they were tautological smoke tests.
- Replace them with subprocess tests that load the SUT inside a `vm`
  wrapper using a custom require whose `.extensions` is genuinely
  undefined, faithfully matching Yarn PnP's bridged require. Verified
  these tests fail on the unfixed source (TypeError reading '.js') and
  pass on the fixed source.
- Assert the new `warnOnce` fires from the registerHook subprocess test.
- Keep the existing jest-process tests as happy-path smoke coverage and
  document the limitation in a header comment.
@bmorros94 bmorros94 marked this pull request as ready for review April 28, 2026 04:45
@bmorros94
Copy link
Copy Markdown
Author

This still applies cleanly to current canary from my local check. Could a maintainer please approve CI and review when you have a chance?

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

Projects

None yet

Development

Successfully merging this pull request may close these issues.

next build crashes on Node 24.15+ and 25.7+ under Yarn PnP: require.extensions undefined in next-config-ts/require-hook.js

1 participant