Repository navigation
CommonJS modules are transformed twice on the registerHooks() path #820
Description
On the module.registerHooks() path (Node.js ≥ 26.2), every CommonJS module that reaches the ESM loader is transformed twice: once by the load hook, whose output is then discarded, and again by the pirates hook.
This covers the entry point in a CommonJS package (because of the --import, Node.js loads it through the ESM loader) and every import/import() of a .cts file or of a .ts/.js file in a CommonJS package.
Where it happens
- The synchronous default load (
defaultLoadSync) always reads the file and hands its source to the hook. Unlike the asynchronous default load behindmodule.register(), it does not returnnullfor CommonJS. loadinregister.mjspasses thatnextLoadstraight tooxcLoad(register.mjs#L242). Because the source is present,transform_outputtakes the source-bearing branch and runs the full transform, codegen, and inline source map (src/lib.rs#L1611-L1685).- The hook then throws that output away for CommonJS and returns
source: null(register.mjs#L253-L258). - Node.js' CommonJS loader reads the file again, and the
pirateshook transforms it a second time (register.mjs#L148).
Measurement
The file is a CommonJS .ts entry of 1.8 MB (12,000 interfaces and functions, no import/export), in a package without "type". Results are on Node.js 26.5.0, macOS arm64, as the median of 15 runs:
| Command | Median |
|---|---|
node --import @oxc-node/core/register main.ts |
118 ms |
| the same, plus a hook that nulls the CommonJS source first | 110 ms |
The output is identical. The saving grows with the size and number of CommonJS modules.
The second row registers this hook before register.mjs, so it runs after it, directly above Node.js' default load:
// null-cjs-source.mjs
import { registerHooks } from 'node:module';
registerHooks({
load(url, context, nextLoad) {
const result = nextLoad(url, context);
return result.format === 'commonjs' ? { ...result, source: null } : result;
},
});$ node --import ./null-cjs-source.mjs --import @oxc-node/core/register main.tsSuggested fix
Do the same inside load: wrap nextLoad so a CommonJS result reaches oxcLoad without source.
function load(url, context, nextLoad) {
if (isCommonJsRequire(context)) {
return nextLoad(url, context);
}
const result = oxcLoad(
url,
context,
(nextUrl, nextContext) => {
const loaded = nextLoad(nextUrl, nextContext);
return loaded.format === "commonjs" ? { ...loaded, source: null } : loaded;
},
HELPER_MODULE_NAME,
);
// ...unchanged
}transform_output then takes the None branch, which is what the module.register() path already does. load_commonjs_esm only parses the file to detect ES module syntax, transforms it only when it flips to an ES module, and rejects a .cts with module syntax as it does today. Everything else is left to the CommonJS loader and pirates, as now.
commonjs-typescript, the format Node.js reports for a .ts file it strips itself, is not commonjs, so it keeps its source, as that translator requires.
Vite+ (voidzero-dev/vite-plus#2936) vendors oxc-node and mirrors register.mjs, so it has the same behavior. We'd rather fix it here and sync than diverge.
Metadata
Metadata
Assignees
Labels
Type
Fields
Priority
Activity
docs(rfc): benchmark the large file as ESM too
build(oxc-node): sync the fix for transforming CommonJS twice
docs(rfc): benchmark the large file as ESM too
build(oxc-node): sync the fix for transforming CommonJS twice
docs(rfc): benchmark the large file as ESM too
build(oxc-node): sync the fix for transforming CommonJS twice
docs(rfc): benchmark the large file as ESM too
build(oxc-node): sync the fix for transforming CommonJS twice
docs(rfc): benchmark the large file as ESM too
build(oxc-node): sync the fix for transforming CommonJS twice
docs(rfc): benchmark the large file as ESM too
build(oxc-node): sync the fix for transforming CommonJS twice
docs(rfc): benchmark the large file as ESM too
build(oxc-node): sync the fix for transforming CommonJS twice
12 remaining items
docs(rfc): benchmark the large file as ESM too
build(oxc-node): sync the fix for transforming CommonJS twice
docs(rfc): benchmark the large file as ESM too
build(oxc-node): sync the fix for transforming CommonJS twice
docs(rfc): benchmark the large file as ESM too
build(oxc-node): sync the fix for transforming CommonJS twice
docs(rfc): benchmark the large file as ESM too
build(oxc-node): sync the fix for transforming CommonJS twice
docs(rfc): benchmark the large file as ESM too
build(oxc-node): sync the fix for transforming CommonJS twice
docs(rfc): benchmark the large file as ESM too
build(oxc-node): sync the fix for transforming CommonJS twice
docs(rfc): benchmark the large file as ESM too
build(oxc-node): sync the fix for transforming CommonJS twice
feat(vpx): run TypeScript scripts with the vendored oxc-node (#2936)