Skip to content

CommonJS modules are transformed twice on the registerHooks() path #820

Closed

Description

@fengmk2

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

  1. The synchronous default load (defaultLoadSync) always reads the file and hands its source to the hook. Unlike the asynchronous default load behind module.register(), it does not return null for CommonJS.
  2. load in register.mjs passes that nextLoad straight to oxcLoad (register.mjs#L242). Because the source is present, transform_output takes the source-bearing branch and runs the full transform, codegen, and inline source map (src/lib.rs#L1611-L1685).
  3. The hook then throws that output away for CommonJS and returns source: null (register.mjs#L253-L258).
  4. Node.js' CommonJS loader reads the file again, and the pirates hook 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.ts

Suggested 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.

Activity

  1. added 13 commits that reference this issue on Oct 12, 2026
    f14cac2
    b2c2cdf
    c8505f0
    9515905
    01fbe85
    fd5d7d0
    80e0848
    a35592d
    051b515
    3805c93
    dfd089b
    9fc909d
    9b9af5c
  2. 12 remaining items

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

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Fields

    Priority

    None yet

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions