Skip to content

fix: migrate to module.registerHooks() and report the module format oxc-node actually generates - #633

Closed
cjnoname wants to merge 15 commits into
oxc-project:mainfrom
cjnoname:fix/migrate-to-register-hooks
Closed

cjnoname wants to merge 15 commits into
oxc-project:mainfrom
cjnoname:fix/migrate-to-register-hooks

Conversation

@cjnoname

@cjnoname cjnoname commented Jun 13, 2026 •

Copy link
Copy Markdown
Contributor

Summary

module.register() is runtime deprecated as DEP0205 from Node.js v26.0.0 and emits a warning on every invocation. This moves the loader to the synchronous, in-thread module.registerHooks() wherever Node.js can actually support it, and keeps module.register() as an unchanged fallback below that.

Thanks for the detailed review — it was right about everything that mattered, and the branch has been reworked accordingly. The three performance changes are gone, the gate is a version check, and every behavioural difference from main is either fixed or documented and pinned by a test. Two of the findings turned out to have a different root cause than the original description claimed; details below.

What the review asked for, and what happened

# Finding Status
1 require() of TypeScript broken on older versions; gate must be a version check Fixed — but the root cause was not SafeSet. See below.
2 require() of .tsx/.jsx/.es/.es6 → Missing field 'format' Fixed. LoadContext.format is optional.
3 JSON imports without an import attribute stop working Fixed. The extension short circuit no longer bypasses the synthesised named exports.
4 Resolved URL no longer percent-encoded Fixed at the source: oxc_resolved_path_to_url percent-encodes, create_resolve percent-decodes. next_resolve is called again as well.
5 tsconfig paths loses to node_modules Fixed. create_resolve is called first again, so oxc-node's resolver still wins.
6 require() resolution order inverted Fixed by removing the custom CommonJS resolution entirely.
7 require() picks the import branch of an exports map Fixed. Resolvers are cached per condition set.
8 .js/.mjs/.cjs no longer transformed; OXC_TRANSFORM_ALL a no-op Fixed. The extension filter is gone.
9 Other resolve hooks never see our files Fixed. resolve asks the chain about the specifier as it was written.
10 TRANSFORM_EXTENSION unanchored, query/fragment counts Fixed. Extensions are matched against the URL's pathname.
11 Top-level await breaks node -r Fixed. register.mjs has no top-level await.
12 napi artifacts not regenerated, no CI drift check Fixed. Regenerated from a wasm build, and CI fails on drift.
13 esm.mjs covered by nothing Fixed. A test-register-fallback job runs the versions below the gate.

resolveCjsSpecifier is gone, and finding 1 had a different cause

The previous description claimed the resolve hook's nextResolve does not consult Module._extensions, where pirates installs its handler. That is not true. A probe that registers a custom extension after registerHooks() shows Node.js honouring it on the require() path regardless of registration order:

OK   ./helper -> helper.ts        # only helper.ts exists, resolved via Module._extensions
OK   ./sib    -> sib.zz           # extension registered after registerHooks()

So the whole resolveCjsSpecifier() binding was unnecessary. resolve and load now hand the entire CommonJS require() path back to Node.js, which is where the asynchronous loader left it too — its hooks cannot serve a synchronous require(), so require() never reached them. That deletes the napi export (the surface is now identical to main), fixes finding 6 by construction, and removes most of hooks.mjs.

The real reason require() broke is different, and it is what the version gate is for. Until v24.18.0 / v26.2.0, a require() made from a CommonJS module that Node.js itself loaded through the ESM CommonJS translator was routed through the ESM resolver: the hook received the already resolved URL together with the import condition set. A loader cannot tell such a require() apart from a real import, so it hands the CommonJS loader an ES module.

node v24.17.0:  resolve helper.ts import   load helper.ts fmt "commonjs"   -> SyntaxError
node v24.18.0:  resolve helper  REQUIRE    _extensions.ts                 -> ok

The SafeSet conditions the review found are real (getCjsConditionsArray() landed in v22.19.0 / v24.5.0) but sit below that floor, so they are covered by the same gate.

The actual root cause of the CommonJS interop trouble

Oxc does not lower ES modules to CommonJS — Module::CommonJS and Module::Preserve both leave import/export in place — and the helper loader adds import declarations to files needing a runtime helper. The load binding nevertheless echoed Node.js' classification straight back, so oxc-node routinely handed Node.js ES module source labelled commonjs. That only worked where Node.js happened to re-detect the module syntax while compiling (loadESMFromCJS); on the CommonJS paths of the synchronous loader it does not.

transform_output now reports module when the code it generated is an ES module, using the parser's has_module_syntax plus the module declarations present after the transform. hooks.mjs uses that verdict to decide ownership: when the output really is CommonJS, Node.js compiles the file through Module._extensions and ignores the source the hook returned, so the hook hands back the untouched original instead of registering a second, conflicting source map for it.

Fixing the mislabel is what lets the gate sit at v22.22.3 / v24.8.0 — the first releases where Node.js can execute an ES module produced for a require() on this path. Every version that emits DEP0205 (v26.0.0+) is above it, so the warning is gone entirely, and both current LTS lines get the in-thread loader.

The unblock list

Asked for Where
Gate on the Node.js version, not typeof registerHooks supportsRegisterHooks() in packages/core/hooks.mjs
Add engines >= 20.19.0 — the release where require(esm) became available by default in the 20 line. >= 20.6.0 would have been an overclaim: below 20.19.0, require() of a transpiled file fails on main too. Bisected across seven 20.x releases.
A CI job on the lowest supported version test-engines-floor (20.19.0) and test-register-fallback (22.18.0, 24.7.0). pnpm needs >= 22.13 and cannot start on 20, so the floor job installs under Node.js 22 and runs integrate-module under 20.19.0.
Defer the load on conditions.includes("require"), make LoadContext.format an Option load() in hooks.mjs; LoadContext in src/lib.rs
Decide JSON explicitly Restored, with specs for default/named/dynamic/attribute imports and for arrays and scalars
Percent-encode in oxc_resolved_path_to_url, or keep calling next_resolve Both. % is escaped as well, and create_resolve percent-decodes on the way in
Run the oxc resolver first so paths keeps winning resolve() calls createResolve first; add_short_circuit is unchanged
In the require branch, try nextResolve first Went further: the whole require() path is Node.js', so there is no oxc-node resolution to order
Key the resolver cache on the condition set Resolvers::resolver() in src/lib.rs, reference counted rather than leaked
Regenerate the napi artifacts, add a drift check Regenerated from a wasm build; the wasm job fails on drift, including a generated file that was never committed
Tests for each packages/integrate-vitest/__tests__/loader.spec.ts, 54 specs across both loaders

The follow-up you suggested for the three performance changes is not part of this PR; they are simply removed here, and can come back with the benchmarks and semantics tests you asked for.

Verification

A 24-scenario matrix run against main's build on 21 Node.js releases from 22.15.0 to 26.8.1, diffed per version:

22.15.0  22.18.0  22.19.0  22.22.2  24.4.0  24.5.0  24.7.0   -> IDENTICAL to main (fallback)
22.22.3  22.23.2  24.8.0   24.12.0  24.17.0 24.18.0 24.19.0
24.20.0  25.0.0   25.9.0   26.0.0   26.1.0  26.2.0  26.3.1   -> only the differences below

There is no case left where this branch reports something different from main, other than three where main is broken and this branch is not:

Scenario main this PR
import { v } from "./x.es6" named export lost works
import { v } from "./dep/mod.ts" (dep is "type": "commonjs") named export lost works
node -r @oxc-node/core/register resolveSync() is not implemented on some versions works

The full suite passes on 15 of those releases with OXC_TRANSFORM_ALL both set and unset (integrate-module 21/21, integrate-module-bundler 22/22, integrate-vitest 53/53; the two specs that assert behaviour only the synchronous hooks can deliver are skipped on the fallback). cargo clippy --all-targets --all-features -- -D warnings is clean and pnpm lint reports only the three warnings already present on main.

packages/integrate-vitest/__tests__/loader.spec.ts adds specs for every finding above plus a table for the version gate, each in a child process with a timeout so a loader that keeps the child alive fails instead of hanging CI.

CI gains a test-register-fallback job on Node.js 22.18.0 and 24.7.0 — the last releases in each line below the gate, which no other job covered — and the wasm build job now fails if the committed napi bindings drift, including a generated file that was never committed.

Remaining difference from main

module.registerHooks() has a single hook chain and runs the most recently registered hook first, so a hook registered before @oxc-node/core/register runs after it. The native resolver calls nextResolve with the URL it resolved, which hid the original specifier from the rest of the chain; resolve now wraps nextResolve so the chain is asked about the specifier as written, falling back to the resolved URL only when Node.js cannot resolve it itself. Observing hooks therefore see ./dep.ts again, exactly as under module.register().

What is left is narrower: oxc-node resolves first, so its URL wins over a redirect from a hook that runs after it. Registering the hook after oxc-node makes the redirect win, on both loaders:

node --import @oxc-node/core/register --import ./my-hooks.mjs ./entry.ts

Both directions are pinned by tests and described in the README.

Not included

cjnoname added 6 commits June 13, 2026 12:26
…ssions

module.register() has been runtime-deprecated as DEP0205 since Node.js
v25.9.0, emitting a deprecation warning on every invocation. Switch to the
synchronous, in-thread module.registerHooks() (Node.js >= 23.5.0 / 22.15.0)
when available, falling back to module.register() on older runtimes.

The synchronous loader routes CommonJS require() and named-export detection
through the customization hooks, which surfaced several interop differences
that the old worker-thread loader hid. Fix them so behaviour is unchanged:

- Defer modules oxc-node does not transform (plain .js/.cjs/.json, native
  addons, …) and anything Node classifies as commonjs back to Node.js, so its
  built-in CommonJS named-export detection (incl. transitive __export(require())
  re-exports) and pirates-based transpilation/source maps keep working.
- Complete missing TypeScript/JSX extensions for CommonJS require() specifiers,
  which the ESM-style resolve hook's nextResolve does not resolve via
  Module._extensions where pirates installs its handler.
- Make ResolveContext/LoadContext conditions and importAttributes optional, as
  the CommonJS require() path does not always provide them.
- Move the loader-thread keep-alive MessageChannel out of the shared hook module
  so it only runs on the dedicated register() loader thread, instead of pinning
  unrelated worker threads (e.g. test runners) and preventing process exit.

Verified on Node.js 24 and 26 with the integrate-module, integrate-module-bundler
and integrate-ava suites all green and no DEP0205 warning.
Replace the filesystem-probing (existsSync) extension completion for CommonJS
require() of TypeScript files with a dedicated native resolver entry,
resolveCjsSpecifier(). It reuses oxc-node's resolver so tsconfig `paths`,
package `exports`, conditions and symlinks are all honoured (the previous
best-effort probing only handled relative/absolute specifiers), and removes the
synchronous filesystem stat loop from the hot resolve path.
- Replace the per-resolve/per-load `new URL(url).pathname` transformability check
  with a substring regex on the raw string (~9.6x faster on the hot path; extension
  characters are never percent-encoded so this is safe), and check the cheap
  `format === 'commonjs'` branch before it in the load hook.
- Load index.js via createRequire instead of a named ESM import. The native binding's
  exports are only statically detectable once oxc-node's hooks are active, so a named
  `import { … } from './index.js'` could fail to find exports under some bootstrap
  entrypoints (e.g. --eval). This also avoids ESM named-export detection overhead.
A specifier that already carries a transformable extension (e.g. `./foo.ts`) is
always oxc-node's to resolve, so the speculative Node `nextResolve` — whose result
would only be discarded — can be skipped, resolving relative TypeScript imports
once instead of twice. ~18% faster on a TypeScript-heavy module graph (200 modules:
36.5ms -> 29.9ms).
For modules oxc-node has fully resolved outside node_modules (URL + concrete
format already known), build the resolve hook output directly rather than calling
nextResolve with the resolved URL. The latter made Node redundantly re-read
package.json and stat the file for a path oxc-node had already resolved.
node_modules modules still defer to Node so CommonJS named-export detection is
unaffected. ~20% faster on a TypeScript-heavy module graph (500 modules:
70.5ms -> 56.2ms).
The synchronous-loader fast paths in hooks.mjs rely on a synchronous nextResolve
(only true under module.registerHooks()). Under the module.register() fallback,
hooks run on a worker thread where nextResolve is asynchronous, so the try/catch
guarding the speculative resolve cannot catch its rejection and tsconfig paths
aliases threw ERR_MODULE_NOT_FOUND. The async worker loader also does not have the
CommonJS interop limitations hooks.mjs works around, so the register() entry
(esm.mjs) now uses the plain resolver directly, while registerHooks keeps using
the optimized hooks.mjs.
@cjnoname

Copy link
Copy Markdown
Contributor Author

@Boshen Please review this PR. Thanks.

@cjnoname

cjnoname commented Jul 6, 2026

Copy link
Copy Markdown
Contributor Author

Hi @Brooooooklyn ? @Boshen ?

@cjnoname

Copy link
Copy Markdown
Contributor Author

@Boshen Is this project dead?

@Brooooooklyn

Copy link
Copy Markdown
Member

Thanks for this — the goal is right and I want it to land. module.register() is deprecated from v25.9, the in-thread loader is where we should be, and the four interop fixes are real fixes. The CommonJS named-export problem that closed #585 is genuinely solved here.

But I can't merge it as it stands. I built both branches and ran the same fixtures against main's register.mjs and this branch's, on Node v24.20.0 (and v24.4.0 where noted). The "behaviour is unchanged" claim doesn't hold — I found 13 differences, 5 of them breaking. Details below, each with the A/B run.

The suite is green on all three packages (integrate-module 21/21, integrate-module-bundler 22/22, integrate-ava 9/9), so none of this shows up in CI.


Blocking

1. require() of TypeScript is broken on Node 22.15–22.18, 23.x, and 24.0–24.4

hooks.mjs:71 tests context.conditions.includes("require"). On those lines Node passes a SafeSet, not an array — getCjsConditionsArray() only arrived in 22.19 / 24.5. SafeSet has no .includes, so the CommonJS branch can never match.

# real node v24.4.0 binary, this branch's build
$ node -e '<registerHooks resolve probe>'
  spec ./helper   ctor SafeSet   isArray false   hasIncludes undefined

$ node-v24.4.0 --import <register.mjs> ./entry.cjs     # entry.cjs: require("./helper"), helper.ts
  main : OK cjs entry: 42
  PR   : SyntaxError: Unexpected token 'export'   helper.ts:1
         at loadCJSModule (node:internal/modules/esm/translators:123:25)

$ node v24.20.0 (array conditions)     main : OK      PR : OK

register.mjs gates on typeof registerHooks === "function", which is true from 22.15. No package.json has an engines field, and CI uses node-version: 22|24 with check-latest: true, so we always land on a version where this happens to work. The gate needs to be a version check, not a feature check.

2. require() of .tsx / .jsx / .es / .es6 throws

hooks.mjs:117 defers only when format === "commonjs". Node never reports that for our files — it's "commonjs-typescript" for .cts, "typescript" for .ts, and undefined for .tsx / .jsx / .es*. So they reach the native load, whose LoadContext.format is Either<String, Null> and not optional.

require("./plain.tsx")   main : tsx: 2   PR : Error: Missing field `format`
require("./plain.jsx")   main : jsx: 4   PR : Error: Missing field `format`
                                              at hooks.mjs:119:10
                                              at Object.newLoader [as .tsx] (pirates/lib/index.js:134:7)

Worth noting this also means the comment at hooks.mjs:41 is inaccurate — CommonJS .ts/.cts are not being handed back to Node; they go through oxcLoad and then pirates.

3. JSON imports without an import attribute stop working

.json fails TRANSFORM_EXTENSION, so the file goes to Node and the create_resolve .json branch plus the transform_output synthesis path (export default json + one export const per key, added in #20) become dead code.

data.json = { "name": "fx-json", "version": "9.9.9" }

                               main    registerHooks                 register() fallback
import d from "./data.json"    works   ERR_IMPORT_ATTRIBUTE_MISSING  works
import { version } from "…"    works   ERR_IMPORT_ATTRIBUTE_MISSING  works
await import("./data.json")    works   ERR_IMPORT_ATTRIBUTE_MISSING  works
… with { type: "json" }        works   works                         works

A user can't add an import attribute to a dependency's source, so any package doing a bare JSON import breaks. The test named "named import from json" doesn't cover it — it reads a property off a default import that already carries with { type: "json" }.

If we want to drop this feature, that's a decision we can make, but it should be deliberate and in the changelog, not a side effect.

4. The resolved URL is no longer percent-encoded

src/lib.rs:575 returns "file://" + path directly instead of routing through next_resolve. oxc_resolved_path_to_url doesn't encode, and Node doesn't validate on the sync path.

# directory named "sp ace", then "中文目录"
main             file:///…/sp%20ace/m.ts    same instance: true
registerHooks    file:///…/sp ace/m.ts      same instance: FALSE   ← loaded twice
register()       TypeError [ERR_INVALID_RETURN_PROPERTY_VALUE]: Expected … a fully
                 resolved URL string … for "responseURL" … but got type string

Any project path with a space or a non-ASCII character gets a split module identity — singletons break, side effects run twice — and a hard crash on the fallback path.

5. tsconfig paths lose to node_modules

hooks.mjs:92 asks Node first and only calls the oxc resolver when Node fails. paths is an override, not a fallback.

# paths: { "shadowed": ["./src/shadowed.ts"] }, with node_modules/shadowed also present
import { who } from "shadowed"    main : TSCONFIG-PATHS    PR : REAL-NODE_MODULES

The existing resolve paths test uses @subdirectory/bar.mjs, which Node can't resolve, so it passes either way and never reaches this.


Should fix before merge

6. require() resolution order is inverted — completeCommonJsTypeScript runs the oxc resolver before Node, and it orders .ts ahead of .json/.node.

# foo.json and foo.ts both present
require("./foo")    main : JSON-FILE    PR : TS-FILE

7. require() picks the import branch of an exports map — resolve_cjs_specifier (src/lib.rs:611) shares the RESOLVER_AND_TSCONFIG OnceLock, so the first caller freezes condition_names for the process.

# dualts exports: { import: "./esm.ts", require: "./cjs.ts" }
require("dualts")    main : picks cjs.ts    PR : picks esm.ts

Note the pre-existing vec![] seeding was harmless — an empty condition set makes the oxc resolver fail, and Node then gives the right answer. Passing ["require","node"] makes it succeed with the wrong one.

8. .js / .mjs / .cjs are no longer transformed at all — and OXC_TRANSFORM_ALL becomes a no-op for them, which contradicts README:90.

# mod.js with a private class field, tsconfig target ES2015
main  source seen by V8: import _classPrivateFieldInitSpec from "@oxc-node/core/helpers/…"
PR    source seen by V8: class C { #p = 7; … }                         ← untouched

Lost with it: experimentalDecorators, emitDecoratorMetadata, class static blocks, using, and .es/.es6 on the ESM side. (JSX in .js is not affected — that already fails on main.)


Smaller

9. The explicit-extension fast path plus the native early return mean other registered resolve hooks never see our files. A counting hook registered before register: main and the fallback see ./dep.ts, registerHooks doesn't. That breaks module mocking and policy hooks.

10. TRANSFORM_EXTENSION is unanchored against the raw string, so query and fragment text counts: file:///w.cjs?cache=.ts, https://h/app.js#v.ts, file:///a/b.json?x=.ts and data:…//.ts all match.

11. The top-level await at register.mjs:27 makes the file an async module — node -r <abs>/register.mjs now throws ERR_REQUIRE_ASYNC_MODULE. --import is fine, so this is minor, but a static import * as hooks from "./hooks.mjs" costs nothing.

12. index.d.ts, oxc-node.wasi.cjs and oxc-node.wasi-browser.js weren't regenerated — no resolveCjsSpecifier, and conditions/importAttributes still typed as required. Runtime survives via module.exports = nativeBinding, which is what the createRequire workaround at the top of hooks.mjs is papering over. We have no CI drift check.

13. registerHooks exists from 22.15/23.5, so every CI job takes the new path and esm.mjs is now covered by nothing — while findings 3, 4 and 8 behave differently on the two paths. That's a fork in behaviour, not a fallback.


What I'd suggest

Three of the blocking items (4, 5, and half of 9) come from the optimisations, not from the migration itself. I'd rather see this split:

  1. This PR: the migration only. Always call nextResolve, keep add_short_circuit, let the native loader decide what it owns. Plus the version gate, the format fix, and a decision on JSON.
  2. A follow-up: the three perf changes, each with a benchmark and a test that pins the semantics it's allowed to change.

Concretely, to unblock:

  • Gate on the Node version, not typeof registerHooks. Add engines and a CI job on the lowest supported version.
  • Defer the load on conditions.includes("require"), and make LoadContext.format an Option.
  • Decide JSON explicitly — restore it or drop it in the changelog.
  • Percent-encode in oxc_resolved_path_to_url, or keep calling next_resolve.
  • Run the oxc resolver first and fall back to Node, so paths keeps winning.
  • In the require branch, try nextResolve first and complete the extension only when Node fails.
  • Key the resolver cache on the condition set instead of first-writer-wins.
  • Regenerate the napi artifacts, and add a drift check.

And tests for each, since the suite is currently green through all of the above. Happy to help with any of these — the hard part (the interop analysis) is already done here and it's good work.

… findings

Reworks the branch along the lines of the review: the three performance changes
are gone, the migration is gated on a version rather than a feature check, and
every behavioural difference from `main` that the review found is fixed and
covered by a test.

The loader
----------
`hooks.mjs` is now the migration and nothing else. `resolve` and `load` hand the
whole CommonJS `require()` path back to Node.js, which is where the asynchronous
`module.register()` loader left it too — its hooks cannot serve a synchronous
`require()`, so `require()` never reached them. Node.js' own CommonJS resolution
honours `Module._extensions`, where `register.mjs` installs `pirates`, so
`require("./foo")` finds `foo.ts` and still prefers `foo.json` over `foo.ts`.
The `resolveCjsSpecifier()` binding the previous revision added for this is
therefore unnecessary and has been removed: the napi surface is unchanged.

The `load` hook additionally defers any module `pirates` will transpile, because
Node.js compiles CommonJS through `Module._extensions` *after* calling the hook;
transforming it in both places registered two source maps for one file and made
stack traces point at generated positions.

`esm.mjs` is byte-identical to `main` again, so the `module.register()` fallback
is unchanged.

The version gate
----------------
`supportsRegisterHooks()` enables the synchronous hooks on Node.js >= 24.18.0 and
>= 26.2.0 only. Before those releases a `require()` made from a CommonJS module
that Node.js itself loaded through the ESM CommonJS translator was routed through
the *ESM* resolver: the `resolve` hook was handed the resolved URL with the
`import` condition set, which a loader cannot tell apart from a real `import`, so
it hands the CommonJS loader an ES module. Feature detecting `registerHooks`
(v22.15.0 / v23.5.0) or the array-shaped conditions (v22.19.0 / v24.5.0) is not
enough. Node.js 22, 23 and 25 keep using `module.register()`; only v26.0.x and
v26.1.x both need the fallback and warn about it (DEP0205 lands in v26.0.0).

Behaviour restored
------------------
* `LoadContext.format` is optional — the `require()` path does not always report
  one, which made `require()` of `.tsx` / `.jsx` / `.es` / `.es6` fail with
  "Missing field `format`".
* JSON imports without an import attribute work again: the resolve hook no longer
  short-circuits `.json` away from the synthesised named exports.
* `create_resolve` calls `next_resolve` with the resolved URL again instead of
  returning its own output, so tsconfig `paths` beats `node_modules`, downstream
  hooks keep seeing every module, and Node.js re-validates the URL.
* Plain `.js` / `.mjs` / `.cjs` files are transformed again, and
  `OXC_TRANSFORM_ALL` still reaches dependencies.
* `register.mjs` has no top-level `await`, so `node -r …/register.mjs` works.

Fixed at the source
-------------------
* `oxc_resolved_path_to_url` percent-encodes the path (WHATWG path percent-encode
  set) and `create_resolve` percent-decodes `file:` specifiers and parent URLs, so
  a project path containing a space or a non-ASCII character resolves and keeps a
  single module identity. Relative imports from inside such a directory now work,
  which they did not before.
* Resolvers are cached per export-condition set instead of first-writer-wins,
  cloned from one base resolver so they share its filesystem cache and tsconfig.
  A CommonJS transform running first no longer freezes the resolver on an empty
  condition set.
* File extensions are matched against a URL's `pathname`, so a `?query` or
  `#fragment` cannot be mistaken for an extension.
* The napi bindings are regenerated from a wasm build, which is the only target
  that refreshes `oxc-node.wasi*.{cjs,js,d.cts}`.

Tests and CI
------------
`packages/integrate-vitest/__tests__/loader.spec.ts` adds 26 specs covering each
of the above in a child process, plus a table for the version gate. They pass on
both loader implementations.

CI gains a `test-register-fallback` job on Node.js 22.18.0 — the oldest release
the toolchain supports and one that takes the `module.register()` path, which no
other job exercised — and the wasm build job now fails if the committed napi
bindings drift.

Known differences from `main`
-----------------------------
`module.registerHooks()` has a single hook chain and runs the most recently
registered hook first, so a hook registered *before* `@oxc-node/core/register`
now runs after it and is handed the resolved URL instead of the original
specifier. It still observes every module; registering it after oxc-node restores
the old view. This is inherent to the synchronous API and is documented in the
README and pinned by a test.
Fixes the root cause behind the CommonJS interop problems in this branch instead
of gating around them.

Oxc does not lower ES modules to CommonJS — `Module::CommonJS` and
`Module::Preserve` both leave `import`/`export` in place — and the helper loader
*adds* `import` declarations to files that need a runtime helper. The `load`
binding nevertheless echoed Node.js' own classification back, so oxc-node
routinely handed Node.js ES module source labelled `commonjs`. That only worked
where Node.js happened to re-detect the module syntax while compiling
(`loadESMFromCJS`); everywhere else it failed with
`SyntaxError: Unexpected token 'export'`, or silently produced a CommonJS facade
with no named exports.

`transform_output` now reports `module` when the code it generated is an ES
module, using the parser's `has_module_syntax` for the input plus the module
declarations present after the transform. `hooks.mjs` uses that verdict to decide
ownership of CommonJS-classified modules: when the output really is CommonJS,
Node.js compiles the file through `Module._extensions` and ignores the source the
hook returned, so the hook hands back the untouched original instead of
registering a second, conflicting source map for it.

Consequences:

* The version gate drops from v24.18.0 / v26.2.0 to v22.22.3 / v24.8.0 — the
  first releases where Node.js can execute an ES module produced for a
  `require()` on this path. Every Node.js version that runtime-deprecates
  `module.register()` (DEP0205, v26.0.0 and later) now uses the synchronous
  hooks, so the warning is gone entirely, and both current LTS lines get the
  in-thread loader.
* `import { … } from "./x.es6"`, and from a `.ts` file inside a
  `"type": "commonjs"` package, keep their named exports. Both silently lost them
  before, on `main` as well.
* `node -r @oxc-node/core/register` works on every supported version instead of
  only the ones whose asynchronous loader implements `resolveSync()`.

Verified against `main` on 21 Node.js releases from 22.15.0 to 26.8.1 with a
24-scenario matrix: no behavioural difference other than the documented hook chain
ordering, plus the three fixes above. The full test suite passes on 15 of those
releases with `OXC_TRANSFORM_ALL` both set and unset.

Also stops `stdin-tty.spec.ts` from failing on any Node.js version that emits a
process deprecation warning, and points the `test-register-fallback` CI job at
22.18.0 and 24.7.0 — the last releases below the new gate.
…olver leak

Follow-up review found one regression and two latent defects; the other reported
issues were verified against `main` and are not real.

Fixed:

* `transform_output` derived the file extension with `Path::extension()` on the whole
  URL, so `data.json?v=1` reported `json?v=1`, skipped the JSON branch and handed the
  JSON text to the JavaScript parser. `main` got away with it because its asynchronous
  loader returns `source: null` for CommonJS and never reaches the transform, while the
  synchronous loader returns the real source — so this was a regression in this branch.
  Extensions are now read from the URL's path, in `transform_output` and in the resolve
  hook's `.json` short circuit.
* `oxc_resolved_path_to_url` did not escape `%`, the escape character itself, so a path
  containing one produced a URL Node.js rejects with `URI malformed`. Broken on `main`
  too; now that the function percent-encodes, encoding `%` completes it.
* The per-condition resolver cache leaked a `Resolver` through `Box::leak` for every
  distinct condition set, which a custom hook can keep inventing. They are reference
  counted now.

Verified as not real:

* CommonJS globals in a CommonJS-scoped `.ts` file. `__dirname` is already `undefined` on
  `main` for any such file that has module syntax, because oxc emits `export {}` for
  erased type exports and Node.js then loads it as an ES module either way. Behaviour is
  identical; the `import` case goes from a hard failure to working.
* Conditional `exports` picking the `import` branch for a `require()` below the version
  gate. Checked on 22.18.0 through 26.8.1, from a translator-loaded CommonJS entry, a
  nested `require()` and `createRequire()`: every version resolves the `require` branch,
  because Node.js resolves the specifier before the hook sees it.

Tests and CI:

* `spawnSync` now has a timeout and asserts the child was not killed — it blocks the event
  loop, so Vitest's timeout could not interrupt a child kept alive by the loader.
* The two "was it transformed" specs parsed whole stdout/stderr and only asserted the two
  runs differed, which `OXC_LOG`/`DEBUG` output and temporary paths could satisfy on their
  own. They assert the reported value now.
* New specs for JSON with `?query` and `#fragment`, and for a path containing `%`.
* The bindings drift check no longer misses a generated file that was never committed.
* `hooks.mjs` is internal, so its declaration file is no longer published.
* README spells out that Node.js 23 is excluded rather than implying "22.22.3 and later".
The resolve hook reports `module` for a JSON module imported without an import
attribute, so that the load hook can synthesise one named export per key. For a
JSON array or scalar there are no keys, and the load hook reported `commonjs`
with a `module.exports = …` body instead.

Under the asynchronous `module.register()` loader Node.js executes that source
directly, so it worked. The synchronous loader routes a `commonjs` JSON URL to
`Module._extensions[".json"]`, which `JSON.parse`s whatever the hook returned and
fails with `Unexpected token 'm', \"module.exp\"... is not valid JSON`.

Emit `export default <json>` with format `module` instead, matching both the
declared format and the object branch right above it. Adds specs for a JSON array,
number and string.
@cjnoname cjnoname changed the title fix: migrate to module.registerHooks() and fix CommonJS interop regressions fix: migrate to module.registerHooks() and report the module format oxc-node actually generates Sep 2, 2026
@cjnoname

cjnoname commented Sep 2, 2026

Copy link
Copy Markdown
Contributor Author

Thanks — this was a genuinely useful review. I reworked the branch and the description is rewritten; summary of what changed, and two places where the diagnosis turned out to be different from what either of us thought.

The three perf changes are gone. create_resolve calls next_resolve with the resolved URL again, add_short_circuit is back, and the extension filter is deleted. That alone fixes findings 4, 5, 8 and half of 9.

resolveCjsSpecifier() is gone too, and finding 1 had a different cause. My original claim that the resolve hook's nextResolve does not consult Module._extensions was simply wrong. Registering a custom extension after registerHooks() and probing shows Node.js honouring it on the require() path, regardless of registration order:

OK   ./helper -> helper.ts     # only helper.ts on disk, resolved through Module._extensions
OK   ./sib    -> sib.zz        # extension registered after registerHooks()

So the binding was unnecessary. resolve and load now hand the whole require() path back to Node.js, which is where the module.register() loader left it anyway. The napi surface is byte-identical to main again, and finding 6 is fixed by construction rather than by ordering logic.

What actually breaks require() is this: until v24.18.0 / v26.2.0, a require() from a CommonJS module that Node.js loaded through the ESM CommonJS translator is routed through the ESM resolver, and the hook gets the already resolved URL with the import conditions. A loader cannot tell it apart from a real import.

v24.17.0:  resolve helper.ts import   load helper.ts fmt "commonjs"   -> SyntaxError
v24.18.0:  resolve helper  REQUIRE    _extensions.ts                 -> ok

The SafeSet you found is real (getCjsConditionsArray() landed in v22.19.0 / v24.5.0) but sits below that floor, so one version gate covers both. You were right that it has to be a version check; it just needed to be a different one.

The root cause behind all the CommonJS interop pain. Oxc does not lower ES modules to CommonJS — Module::CommonJS and Module::Preserve both keep import/export — and the helper loader adds import declarations. The load binding echoed Node.js' classification back unchanged, so oxc-node has been handing Node.js ES module source labelled commonjs, and only got away with it where Node.js re-detects the module syntax while compiling. transform_output now reports module when the generated code is an ES module, and hooks.mjs uses that verdict to decide whether pirates or the load hook owns a file, so exactly one of them transforms it and only one source map is registered.

That is also what lets the gate sit at v22.22.3 / v24.8.0 instead of v24.18.0: every version that emits DEP0205 is above it, so the warning is gone completely, and both current LTS lines get the in-thread loader.

On the rest of the list: LoadContext.format is optional (2), the JSON short circuit no longer bypasses the synthesised named exports (3), oxc_resolved_path_to_url percent-encodes and create_resolve percent-decodes so a path with a space or a non-ASCII character keeps one module identity (4 — a relative import from inside such a directory works now, which it did not before), resolvers are cached per condition set (7), extensions are matched against the URL's pathname (10), no top-level await in register.mjs (11), artifacts regenerated from a wasm build with a CI drift check (12), and a test-register-fallback job runs 22.18.0 and 24.7.0 — the last release in each line below the gate — so esm.mjs is covered (13).

Finding 9 I could not fully fix, and I would rather say so than paper over it. registerHooks() has one chain and runs the most recently registered hook first, so a hook registered before oxc-node now runs after it and receives the resolved URL. It still sees every module, and registering it after oxc-node gives the old view. Making it see the original specifier means not resolving specifiers ourselves, which is finding 5. It is in the README and pinned by tests in both directions.

Verification. A 24-scenario matrix against main's build on 21 releases from 22.15.0 to 26.8.1. Everything below the gate is byte-identical to main; above it the only diffs are the hook ordering above and three cases where main is broken and this is not (import of a .es6 module, import of a .ts file in a "type": "commonjs" package, and node -r). Full suite green on 15 of those releases with OXC_TRANSFORM_ALL set and unset, plus clippy and lint.

Every finding has a spec in packages/integrate-vitest/__tests__/loader.spec.ts, each in a child process with a timeout — your point about the suite being green through all of this was fair, and spawnSync without a timeout would have hung CI rather than failed it.

Two unrelated bugs I hit while writing those tests are split out rather than bundled here:

  • fix: honour useDefineForClassFields instead of inverting it #742 — useDefineForClassFields is inverted. set_public_class_fields and loose mean [[Set]], so they have to be the negation of it; they were assigned it directly. useDefineForClassFields: false is what NestJS and friends ask for and we were doing the opposite. The default ignored target as well. Checked against tsc from our own devDependencies across 13 target/option combinations.
  • fix: emit ESM imports for injected helpers in ES modules #743 — injected helpers use require() in ES modules. .ts/.js are ModuleKind::Unambiguous, so a file with no import/export looks like a script and gets require(), which then runs in an ES module because Node.js decides from package.json.

Both are independent of this PR and of each other. Windows UNC file URLs are still unsupported — real, but identical to main and I cannot test it here, so I left it alone rather than guess.

Happy to split this further if you would rather review the migration and the format fix separately.

Finding 9 of the review, which the previous revision documented as inherent to
`module.registerHooks()`. It is not.

The native resolver resolves the specifier and then calls `nextResolve` with the
resolved URL, to have Node.js validate it and fill in the resolution metadata.
Under `module.register()` that was invisible: oxc-node's hooks ran on a separate
loader thread, after every in-thread hook, so those hooks always saw the original
specifier. With a single chain oxc-node runs first, and passing the resolved URL
down hid the specifier from module mocking and policy hooks.

`resolve` now wraps `nextResolve` so the rest of the chain is asked about the
specifier as written, and only falls back to the resolved URL when Node.js cannot
resolve it — a tsconfig `paths` alias, an extensionless TypeScript file. A hook
registered before oxc-node observes `./dep.ts` again, exactly as under
`module.register()`, and the 24-scenario matrix across Node.js 22.18.0 to 26.8.1
now shows no case where this branch reports something different from `main`.

What is left is narrower and now has a test of its own rather than a paragraph:
oxc-node resolves first, so its URL wins over a *redirect* from a hook that runs
after it. Registering the hook after oxc-node makes the redirect win, on both
loaders.
@cjnoname

cjnoname commented Sep 2, 2026

Copy link
Copy Markdown
Contributor Author

Follow-up on finding 9: you were right that it mattered, and I was wrong to call it inherent. It is fixed now.

The native resolver resolves the specifier and then calls nextResolve with the resolved URL, to have Node.js validate it and fill in the metadata. Under module.register() that was invisible — oxc-node's hooks ran on a separate loader thread, after every in-thread hook, so those hooks always saw the original specifier. With a single chain oxc-node runs first, and passing the URL down hid the specifier.

resolve now wraps nextResolve so the chain is asked about the specifier as written, falling back to the resolved URL only when Node.js cannot resolve it itself (tsconfig paths alias, extensionless TypeScript):

function withOriginalSpecifier(specifier, nextResolve) {
  return (resolved, context) => {
    if (resolved !== specifier) {
      let output;
      try {
        output = nextResolve(specifier, context);
      } catch {
        return nextResolve(resolved, context);
      }
      if (output !== null && typeof output === "object" && !("then" in output)) {
        return { ...output, url: resolved };
      }
    }
    return nextResolve(resolved, context);
  };
}

A hook registered before oxc-node observes ./dep.ts again, and the 24-scenario matrix across 22.18.0 to 26.8.1 now has no case where this branch reports something different from main — the only diffs left are three where main is broken and this is not. tsconfig paths still wins, source maps are unchanged, and lint is back to the three warnings main already has.

What remains is narrower and has its own test rather than a paragraph: oxc-node resolves first, so its URL wins over a redirect from a hook that runs after it. Registering the hook after oxc-node makes the redirect win, on both loaders. The test asserts both orders and both loaders.

Also filed #744 for the Windows UNC file URL handling — it was only a sentence in the description before, which was the wrong place for it. Behaviour is the same on main; I have no Windows machine to verify a fix on, so I described the problem and the pathToFileURL/fileURLToPath rules it should follow instead of guessing at a patch.

`engines` said `>= 20.6.0`, the release that added `module.register()`. That was an
overclaim: oxc does not lower ES modules to CommonJS, so `require()` of a
transpiled TypeScript file needs `require(esm)`, which only became available by
default in the 20 line in **v20.19.0**. Below that it fails with
`SyntaxError: Unexpected token 'export'` — on `main` as well, so this is a
correction to the claim rather than a behaviour change.

Bisected across 20.6.0, 20.10.0, 20.16.0, 20.17.0, 20.18.0, 20.18.3 and 20.19.0.

The review asked for a CI job on the lowest supported version, and until now the
lowest was 22.18.0, so the floor was not covered at all. pnpm itself requires
Node.js >= 22.13 and cannot even start on 20, so `test-engines-floor` installs the
suite under Node.js 22 and then runs `integrate-module` (21 specs) under 20.19.0
directly, after asserting that this version really does take the
`module.register()` fallback.
@cjnoname

cjnoname commented Sep 2, 2026

Copy link
Copy Markdown
Contributor Author

One more correction, this time to something I introduced rather than to the original branch.

I had added "engines": { "node": ">= 20.6.0" } — the release that added module.register(). That is an overclaim. Oxc does not lower ES modules to CommonJS, so require() of a transpiled file needs require(esm), which only became available by default in the 20 line in v20.19.0. Below that it fails with SyntaxError: Unexpected token 'export' — on main too, so the claim was wrong, not the behaviour. Bisected:

20.6.0  20.10.0  20.16.0  20.17.0  20.18.0  20.18.3  -> SyntaxError (main and this branch)
20.19.0                                              -> ok        (main and this branch)

engines now says >= 20.19.0, and your point about a CI job on the lowest supported version is properly closed: test-engines-floor runs integrate-module (21 specs) on 20.19.0 after asserting that version takes the module.register() fallback. pnpm itself needs Node.js >= 22.13 and cannot start on 20, so the job installs under 22 and switches to 20.19.0 to run — which is also why nothing in CI reached the floor before.

The description now carries a table mapping each item of your unblock list to where it landed. Current state:

  • Version gate, engines, and CI at both the floor (20.19.0) and the last fallback release in each line (22.18.0, 24.7.0)
  • LoadContext.format optional, require() deferred, JSON restored with specs for objects, arrays and scalars
  • Percent-encoding and next_resolve — plus % itself, which neither of us caught: a path containing one produced a URL Node.js rejects with URI malformed, on main as well
  • paths wins, resolver cached per condition set and reference counted rather than leaked, extensions read from the URL's pathname
  • Artifacts regenerated from a wasm build, drift check that also catches an uncommitted generated file
  • 54 specs across both loaders; a 24-scenario matrix on 21 releases from 22.15.0 to 26.8.1 with no case where this branch differs from main except three where main is broken

Two things I did not do: the follow-up PR for the three performance changes — they are just removed here, and can come back with the benchmarks and semantics tests you asked for — and Windows UNC file URLs, which are #744.

Findings I got wrong along the way and corrected: the Module._extensions premise, calling finding 9 inherent, the JSON array regression, and this engines floor. Each is described where it landed rather than quietly fixed.

…lass-field polarity

The spec asserted the transformed dependency reports `setterCalled: true`, which
holds under the current `useDefineForClassFields` mapping and flips once oxc-project#742
corrects it. It now runs the dependency under both values of the option and
asserts they disagree when it was transformed and agree when it was not, which is
true under either mapping.
@cjnoname

cjnoname commented Sep 3, 2026

Copy link
Copy Markdown
Contributor Author

Cross-PR note, found by merging all three of these together and running the suites rather than assuming they compose.

They do not conflict textually — git merges all three cleanly — but #742 changes the class-field semantics that fixtures in the other two relied on, so whichever landed second would have had red CI:

Both fixtures are now independent of it:

With those in place, all three merged together: 73/73 on Node.js 22.22.3, 24.8.0, 24.20.0, 26.2.0 and 26.8.1, 71 passed + 2 skipped on 22.18.0 and 24.7.0 (the two specs that need the synchronous hooks), and the engines floor job green on 20.19.0. Merge order does not matter now.

No product code changed for this — only the two fixtures.

@cjnoname

cjnoname commented Sep 3, 2026

Copy link
Copy Markdown
Contributor Author

Merge note for #633 and #745, found by merging all four open PRs together and building rather than trusting the merge result.

git merge reports success and produces code that does not compile. Both PRs touch the same two places in src/lib.rs, and git interleaves the two edits instead of combining them:

            if !is_json
                && env::var("OXC_TRANSFORM_ALL")
                    .map(|value| value.is_empty() || value == "0" || value == "false")
                    .unwrap_or(true)
            if env::var("OXC_TRANSFORM_ALL")          // <- second copy of the condition
                .map(|value| value.is_empty() || value == "0" || value == "false")
                .unwrap_or(true)
                && url.contains("/node_modules/")
            {

Three things need doing by hand, all mechanical:

  1. Drop the pre-resolver .json short circuit that fix: migrate to module.registerHooks() and report the module format oxc-node actually generates #633 still carries — fix: resolve JSON modules with oxc-node's resolver #745 deliberately removes it, and a clean merge keeps it, which silently defeats fix: resolve JSON modules with oxc-node's resolver #745.
  2. json_format() has to read import_attributes as the Option fix: migrate to module.registerHooks() and report the module format oxc-node actually generates #633 makes of it.
  3. Keep one copy of url_path() and one copy of the OXC_TRANSFORM_ALL condition.

With those resolved, all four merged together: 87/87 on Node.js 22.22.3, 24.8.0, 24.20.0, 26.2.0 and 26.8.1, 85 passed + 2 skipped on 22.18.0 and 24.7.0 (the specs that need the synchronous hooks), integrate-module green on the 20.19.0 engines floor, and clippy and lint clean.

Point 1 is the one worth watching: it is the only one the compiler does not catch. If #633 lands first and #745 is merged without noticing it, JSON goes back to being short circuited before the resolver and #726 silently regresses — with #745's own specs still passing, because the fallback path they exercise still reports the right format. I would rather flag it than let it slip through.

#742 and #743 merge cleanly with everything.

Brooooooklyn added a commit that referenced this pull request Sep 11, 2026
Closes #726.

## The bug

`create_resolve` short circuited on any specifier ending in `.json`
**before its own resolver ran**, handing the unresolved specifier to
Node.js. Node.js knows nothing about tsconfig `paths`, so an aliased
JSON import failed:

```jsonc
// tsconfig.json
{ "compilerOptions": { "baseUrl": ".", "paths": { "@data/*": ["./src/*"] } } }
```

```ts
import data from "@data/config.json";                          // ERR_MODULE_NOT_FOUND
import data from "@data/config.json" with { type: "json" };    // ERR_MODULE_NOT_FOUND
```

A `.ts` file behind the same alias resolves fine, so this is specific to
JSON. Both forms failed — the attribute form because the separate "has
import attributes" bail out also returns Node.js' resolution of the
original specifier.

## The fix

JSON is resolved like every other specifier, and the format is decided
from the resolved path afterwards:

- an import attribute was written → `json`, which is what Node.js wants
- otherwise → `module`, so the `load` hook can synthesise a default
export plus one named export per key

When oxc-node's resolver cannot resolve the specifier the old short
circuit still applies, so nothing changes for anything it already
handled.

Two things had to follow from letting JSON reach the resolver:

**The extension has to be read from the URL's path.**
`Path::extension()` on the whole URL reports `json?v=1` for
`./data.json?v=1`. That was latent before — the specifier check kept
those away from the resolver — and became observable immediately: the
JSON branch was skipped and the JSON went to the JavaScript parser.

**Turning JSON into a module is not a code transform**, so it no longer
sits behind the `OXC_TRANSFORM_ALL` check for `node_modules`. That check
is about transpiling dependency *source*; skipping this handed Node.js
raw JSON to execute as an ES module. This also fixes a second broken
case:

```ts
// node_modules/pkg/package.json: { "exports": { "./config": "./cfg/real.json" } }
import data from "pkg/config";        // before: ERR_IMPORT_ATTRIBUTE_MISSING   after: works
import { v } from "pkg/config";       // before: ERR_IMPORT_ATTRIBUTE_MISSING   after: works
import data from "pkg/config" with { type: "json" };   // worked before, still works
```

## Verification

`packages/integrate-vitest/__tests__/json-modules.spec.ts` covers the
alias with and without an import attribute, named and dynamic imports
through the alias, an `exports` subpath in a dependency, and the
relative, `?query`, `#fragment`, array, number and string cases that
already worked — the last group compared against `main` to confirm they
are unchanged.

`integrate-module` (21/21), `integrate-module-bundler` (22/22) and
`integrate-vitest` (23/23) pass on Node.js 22.23.2 and 24.20.0 with
`OXC_TRANSFORM_ALL` both set and unset, and `integrate-module` passes on
20.19.0. `cargo clippy --all-targets --all-features -- -D warnings` and
`pnpm lint` are clean. No napi surface change.

## Note

#633 contains the same `url_path` helper, for the same reason. The two
are logically independent; whichever lands second drops the duplicate.

---------

Co-authored-by: LongYinan <lynweklm@gmail.com>
Co-authored-by: cjnoname <cjnoname@users.noreply.github.com>
@cjnoname cjnoname closed this Sep 11, 2026
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