Skip to content

fix: register module hooks synchronously on Node.js >= 26.2.0 - #764

Merged
Brooooooklyn merged 3 commits into
oxc-project:mainfrom
cjnoname:fix/register-hooks-sync
Sep 18, 2026
Merged

Brooooooklyn merged 3 commits into
oxc-project:mainfrom
cjnoname:fix/register-hooks-sync

Conversation

@cjnoname

Copy link
Copy Markdown
Contributor

Problem

packages/core/register.mjs calls module.register(). Node.js runtime-deprecated that
API in v25.9.0 (DEP0205), so every
node --import @oxc-node/core/register now prints:

(node:94305) [DEP0205] DeprecationWarning: `module.register()` is deprecated. Use `module.registerHooks()` instead.

This is not only cosmetic — pnpm test already fails on Node.js 26 on main, because
four tests assert an empty stderr and the warning lands there. CI runs Node 22 and 24
only, so it has stayed invisible:

# main, unmodified, Node v26.8.1
 Test Files  2 failed | 7 passed | 1 skipped (10)
      Tests  4 failed | 70 passed | 7 skipped (81)

 FAIL  __tests__/stdin-tty.spec.ts > CLI properly handles stdin piping
 FAIL  __tests__/tsconfig-discovery.spec.ts > a broken project reference in an ancestor that does not own the file breaks nothing
 FAIL  __tests__/tsconfig-discovery.spec.ts > tsconfig paths apply to JavaScript importers with allowJs unset
 FAIL  __tests__/tsconfig-discovery.spec.ts > a project directory with a space and non-ASCII characters still resolves

All four are the same diff: + (node:1427) [DEP0205] DeprecationWarning: ...

Change

Use the synchronous, in-thread module.registerHooks() where it is complete, and keep
module.register() everywhere else. The ESM path is otherwise unchanged — resolve still
hands everything to createResolve and load to oxcLoad, exactly as esm.mjs does.

Three things make this more than a one-line swap. Each was measured against release
binaries rather than reasoned about, and a naive replacement fails on all three.

1. registerHooks() shows require() to the hooks; register() never did

The CommonJS resolve/load context carries no importAttributes, and both
createResolve and load reject a context without it:

Error: Missing field `importAttributes`
    at resolve (.../register.mjs:11:12)
    at resolveForCJSWithHooks (node:internal/modules/cjs/loader:1217:25)

So a request whose conditions contain require goes to nextResolve/nextLoad.
Node.js resolves those correctly by itself, because the pirates hook has put the
TypeScript extensions into Module._extensions — that is what lets its CommonJS resolver
complete require('./dep') to ./dep.ts and require('./sub') to ./sub/index.ts.

2. A file that oxc-node settles on as CommonJS must go back to nextLoad

Otherwise the pirates hook no longer produces the inline source map, and stack trace
precision regresses. cli.spec.ts covers exactly this:

reported location
module.register() stacktrace-cjs.cts:6:12
naive registerHooks() stacktrace-cjs.cts:5:10

Calling oxcLoad first and only then deferring is what keeps #759 intact — a
CommonJS-reported file that actually contains ESM syntax still runs as an ES module.

3. registerHooks needs a version floor, not a typeof check

It exists from v22.15.0/v23.5.0, but two defects had to be fixed first. Bisected over
every available minor:

Node.js ESM importing a CJS package require() in an imported CJS entry
22.14.0 (no registerHooks) — fallback — fallback
22.15.0 – 22.18.0
22.19.0 – 22.22.1
23.5.0 – 23.11.1 (EOL)
24.0.0 – 24.4.1
24.5.0 – 25.9.0
26.0.0 – 26.1.0
26.2.0+

Those boundaries are not arbitrary — they match the Node.js fixes exactly:

  • nodejs/node#59011 "module: fix conditions
    override in synchronous resolve hooks"
    — v24.5.0 (fe0195fdcc), backported to v22.19.0
    (0eec5cc492), never backported to the end-of-life 23.x line. Without it,
    import { jsx } from 'react/jsx-runtime' fails with does not provide an export named 'jsx'.
  • nodejs/node#62920 "module: fix sync hook
    short-circuit in require() in imported CJS"
    — v26.2.0 (96f19a16d0). Without it, a .ts
    entry in a CommonJS package cannot require() its own files.

Hence the floor is v26.2.0. Below it the fallback runs and nothing changes for those
runtimes; v26.0/v26.1 keep the warning, which is the honest outcome given the defect.

Verification

34 release binaries, v22.14.0 → v26.8.2 (every available 22.x/23.x/24.x/25.x/26.x
minor), against three fixtures: a CommonJS require() tree (extensionless .ts,
directory index.ts, explicit .cts), an ESM tree (extensionless, .mts, tsconfig
paths, .tsx + react/jsx-runtime), and a createRequire tree.

versions tested: 34
failures:        0
still warning:   26.0.0 26.1.0

Upstream suite, same machine, Node v26.8.1:

result
main `4 failed
this PR 75 passed

vp fmt and vp lint are clean.

Test added

require() completing an extensionless TypeScript specifier had no coverage. The new case
in cjs-esm-syntax.spec.ts fails with Cannot find module './dep' if the pirates hook
stops registering those extensions — verified by removing it.

`register.mjs` calls `module.register()`, which Node.js runtime-deprecated in
v25.9.0. Every `node --import @oxc-node/core/register` therefore prints DEP0205
on a current runtime, and because four tests in this repository assert an empty
stderr, `pnpm test` already fails on Node.js 26 — CI only runs 22 and 24, so it
has not been visible.

Use the synchronous, in-thread `module.registerHooks()` where it is complete, and
keep `module.register()` everywhere else. Three things make this more than a
one-line swap, each verified against release binaries:

- `registerHooks()` shows `require()` to the hooks, which `register()` never did.
  The CommonJS resolve/load context carries no `importAttributes`, and both
  `createResolve` and `load` reject a context without it, so those requests are
  routed to `nextResolve`/`nextLoad` — Node.js resolves them itself, since the
  `pirates` hook has registered the TypeScript extensions in `Module._extensions`.
- A file that oxc-node settles on as CommonJS goes back to `nextLoad`, so the
  `pirates` hook keeps producing its inline source map. Returning the transformed
  source instead reported a throw in a `.cts` entry at 5:10 rather than 6:12,
  which `cli.spec.ts` covers.
- `registerHooks` exists from v22.15.0/v23.5.0, but nodejs/node#59011
  (v24.5.0, backported to v22.19.0, never to the end-of-life 23.x line) and
  nodejs/node#62920 (v26.2.0) had to land first. Below v26.2.0 the fallback runs,
  so nothing changes for those runtimes.

Verified on 34 release binaries from v22.14.0 to v26.8.2 — every 22.x, 23.x, 24.x,
25.x and 26.x minor available — against a CommonJS `require()` fixture, an ESM
fixture covering tsconfig paths/`.mts`/`.tsx`, and a `createRequire` fixture: no
failures anywhere, and DEP0205 gone from v26.2.0 up. The suite goes from
4 failed | 70 passed to 75 passed.

Adds a test for `require()` completing an extensionless TypeScript specifier,
which nothing covered: it fails with MODULE_NOT_FOUND if the `pirates` hook stops
registering those extensions.
cjnoname added a commit to documonster/documonster that referenced this pull request Sep 15, 2026
… invalidated

vitest 4 → 5, pnpm 11 → 12, @oxc-node/core 0.1.0 → 0.1.2, oxfmt 0.64 → 0.68,
oxlint 1.79 → 1.83, @rspack/core 2.1 → 2.2, rolldown 1.2.5 → 1.2.8, playwright
1.62 → 1.63, plus @types/node and fast-xml-parser.

`@oxc-node/core` 0.1.2 does not remove the need for the patch: `register.mjs` is
byte-identical from 0.1.0 through 0.1.2 and on upstream `main` — verified with
`cmp`, both at blob 9658531 — and the 0.1.1/0.1.2 changelogs only cover
transform, resolve and UNC-path fixes. Dropping the patch brought DEP0205 back to
every `node --import @oxc-node/core/register`, which is every gate script here.

The patch is rebuilt rather than re-keyed, because the old one was wrong in a way
this package never exercised: it routed a CommonJS `require()` into oxc's
resolver, which rejects a context without `importAttributes`, so any `.ts` entry
in a `"type": "commonjs"` scope died with ``Missing field `importAttributes` ``.
Nothing here is CommonJS, so it never showed. The replacement keeps `require()`
on Node's own resolution, hands a file oxc reports as CommonJS back to the
`pirates` hook so its source map stays accurate, and gates `registerHooks` on
Node >= 26.2 — it exists from 22.15 but only became a complete replacement once
nodejs/node#59011 (24.5.0, backported to 22.19.0) and nodejs/node#62920 (26.2.0)
landed. Below that the deprecated path still runs, so nothing regresses.
Measured across 34 release binaries from 22.14.0 to 26.8.2. Submitted upstream as
oxc-project/oxc-node#764.

The satellite's shared devDependencies move to a pnpm catalog. It had been left
on vitest 4 while the root went to 5, which put two vitest majors in the tree;
declaring the version once makes that drift structurally impossible, and
`pnpm pack` rewrites `catalog:` to a real range so a consumer never sees it.

`allowBuilds.esbuild` is now false. esbuild is still used — `scripts/treeshake-
verify.ts` is one of its three bundlers — but its postinstall only speeds up the
`esbuild` CLI, which nothing here invokes; the JS API spawns the platform binary
directly. `buildSync` and all 111 treeshake scenarios pass without it.

The two stream tests are reformatted by oxfmt 0.68, which breaks a trailing
function expression's parameters differently. No behaviour change.

Verified: 20397 node tests, 14454 browser tests, 592 satellite tests, `pnpm
check`, and `pnpm verify:treeshake` 111/111.

@Brooooooklyn Brooooooklyn left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Excellent work — the design is right and the version-floor research is thorough. Verified end-to-end on Node 26.9.0 (75/75 tests, no DEP0205) and 24.11.1 (fallback identical). Two real regressions vs main to fix before merge, both with small fixes.

1. import of .node addons / non-UTF-8 commonjs files crashes in oxcLoad

nextLoad is not equivalent across the two APIs:

  • async defaultLoad (the old register() path) returns source: null for commonjstransform_output takes the None branch → load_commonjs_esm's SourceType::from_path/from_utf8 guards return Ok(None) → Node's CJS machinery handles it.
  • sync defaultLoadSync always reads the fileoxcLoad receives {format: "commonjs", source: <raw bytes>}transform_outputoxc_transformtry_as_str → UTF-8 decode throws.

resolve reports "commonjs" for .node ("cjs" | "cts" | "node" => Noneunwrap_or("commonjs") in create_resolve), so the throw happens inside oxcLoad before the format === "commonjs" deferral can run.

Repro (fixture: any dir + symlinked @oxc-node/core):

// entry.mts — a real .node binary next to it
import addon from "./addon.node";
console.log("addon:", typeof addon);
result
Node 24.x (register()) addon: object
Node 26.9 (registerHooks) Error: Failed to convert Uint8Array to Vec<u8> at register.mjs:93

Same crash for a latin-1-encoded .js in a "type": "commonjs" package (prints caf� on the old path). import of .node is a legitimate napi pattern — this will hit real users on 26.2+.

Suggested fix: in transform_output, treat an undecodable source on a non-JSON commonjs result like source: null (return {format, source: null, response_url} → CJS machinery) instead of propagating the decode error. load_commonjs_esm already reads the file itself, so the #759 flip still works for real JS/TS.

2. isCommonJsRequire misroutes imports under --conditions=require

--conditions appends custom conditions to every request's condition set. Verified on 26.9:

node --import @oxc-node/core/register --conditions=require ./entry.mts
Scenario Old path This PR
import "./dyn" (extensionless .ts) extensionless: 5 ERR_MODULE_NOT_FOUND
import "./modpkg/flip.ts" (ESM syntax in CJS pkg — the #759 case) format: module ERR_REQUIRE_CYCLE_MODULE

--conditions=import is the mirror hazard: it injects "import" into require contexts, so an includes("import") check would misfire the other way and crash napi on the missing field.

Suggested fix: discriminate on the field that actually crashes — context.importAttributes === undefined. Verified via probes on 26.9: require contexts never carry importAttributes; import contexts always have it ({}), under both poisoned flags.

Non-blocking notes

  • The v26.2.0 floor is conservative. nodejs/node#62920 only bites when a sync load hook customizes imported CJS (returns source) — this implementation never does, it always defers. Forcing registerHooks on 24.11.1/22.23.2 passes the whole suite including the require() fixtures; the real binding constraint is #59011 (24.5.0, backported 22.19.0, never 23.x). A lower floor would additionally silence DEP0205 on 26.0/26.1. Keeping 26.2 is a defensible safety margin — your call.
  • Double nextLoad is legal but wasteful — no once-guard in nextStep; each call re-reads the file (~3 reads + 2 transforms per imported CJS file). {format: "commonjs", source: null} would be equivalent (null source → loadCJSModuleWithModuleLoad) with one less pass. Correct as-is.
  • result.format === "commonjs" misses commonjs-typescript (Node-detected format for node_modules .ts resolved with format: None). Harmless today — the passthrough is equivalent — but the invariant is narrower than the comment implies.
  • Test comment nit: Module._extensions is consulted per-require(), so reordering addHook/registerHooks inside register.mjs wouldn't actually fail — removing addHook is what breaks. Comment overstates the ordering requirement.

Verified working (no action needed)

require() inside imported CJS (.ts/.json/.cjs, dirs, builtins) · require(esm) · createRequire · import() in CJS · worker threads · eval/stdin · .cjs vs .mts/.ts entry condition assignment · stack-trace precision (cli.spec.ts 6:12) · JSON modules · esm.mjs/--loader fallback · promise paths (sync hooks never return Either::B).

Branch synced with main (merge e9f8cd7); suite re-verified green on 26.9 post-merge.

Two requests the asynchronous `module.register()` never showed the hooks reach them
under `module.registerHooks()`, and both regressed:

- Its default load is synchronous and always reads the file, so a `commonjs` result
  arrives with its bytes attached — including for a `.node` addon, which resolves as
  `commonjs`, and for a latin-1 CommonJS file. `transform_output` then failed to decode
  them ("Failed to convert Uint8Array to Vec<u8>"), so `import addon from './x.node'`
  crashed where it printed an object before. Hand such a source back the way the
  asynchronous default load did — no source, CommonJS machinery reads the file — instead
  of propagating the decode error.
- `require()` was told apart by the `require` export condition, but `--conditions`
  appends its values to *every* request: under `--conditions=require` an extensionless
  `import` failed with ERR_MODULE_NOT_FOUND and a CommonJS-reported file with ESM syntax
  with ERR_REQUIRE_CYCLE_MODULE, and `--conditions=import` is the mirror hazard.
  Discriminate on `importAttributes` instead — the field the native hooks require anyway,
  absent on every CommonJS context and present, if empty, on every ESM one.

The load hook now returns `{ source: null }` for a CommonJS result rather than calling
`nextLoad` a second time: same outcome, one file read and one transform less, and it is
the shape that never trips nodejs/node#62920, so that defect no longer bears on the
version floor — v26.2.0 is kept as a deliberate margin. CI gains a Node.js 26 job, since
nothing in the matrix exercised the synchronous path.
@cjnoname

Copy link
Copy Markdown
Contributor Author

Thanks — both regressions confirmed locally on v26.8.1 (identical repros) and both fixed in 5125e3c. Verified: the whole suite passes on v26.8.1 (sync path) and v24.16.0 (fallback), 83 passed, and the four new tests fail on the previous commit for exactly the four reported reasons.

1. Binary / non-UTF-8 commonjs source — fixed as suggested, in transform_output: a commonjs* result whose source does not decode is handed back as source: null and the CommonJS machinery reads the file, which is what the asynchronous default load did implicitly. import addon from './addon.node' prints addon: object again, and a latin-1 file in a "type": "commonjs" package prints its replacement character as before. JSON keeps its decode error, since invalid UTF-8 there is a real failure.

2. isCommonJsRequire — now context?.importAttributes === undefined, with the reasoning in the comment. Re-probed on v26.8.1 to confirm your finding: with --conditions=require, an import still carries importAttributes ({}) and merely has require appended to conditions, while a require() carries none. Tests cover both directions under --conditions=require and --conditions=import, so neither flag can be poisoned again without a failure.

Non-blocking notes

  • Double nextLoad — removed. The hook now returns { format, source: null, responseURL } directly: one read and one transform less per imported CommonJS file, and stack-trace precision is unchanged (cli.spec.ts still reports 6:12).
  • commonjs-typescript — the test is now result.format.startsWith("commonjs"), so Node.js' own type-stripping format takes the same path instead of relying on the passthrough being equivalent.
  • Version floor — kept at v26.2.0, comment corrected to say why: modules: fix sync hook short-circuit in require() in imported CJS nodejs/node#62920 only bites a hook that returns source for imported CommonJS, which this one never does, so the strictly required floor is #59011's v24.5.0 / v22.19.0. Keeping v26.2.0 means every runtime below it behaves exactly as it does today; it costs DEP0205 on v25.9–v26.1, which I would rather pay than widen the blast radius of a new code path across the 22/24 LTS lines. Happy to lower it if you prefer the warning gone everywhere — the diff is one line.
  • CI — added a Node.js 26 job to the macOS/Windows binding matrix. Nothing in the matrix ran the synchronous path before, which is why the original DEP0205 failure was invisible.
  • Test comment — reworded: dropping the pirates hook is what breaks the extensionless require(), not the registration order.

@Brooooooklyn Brooooooklyn left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Re-verified 5125e3c end-to-end — both findings are fixed and confirmed working on real binaries.

Verified

.node / non-UTF-8 commonjs deferral — the try_as_str guard in transform_output sits in the Some(source) arm before the node_modules skip, so .node and latin-1 defer correctly whether local or under node_modules:

Scenario Before (26.9) Now (26.9)
import './addon.node' Failed to convert Uint8Array addon: object
import './latin.js' (0xe9) same crash caf� (4 chars, matches old path)

JSON correctly keeps its decode error (invalid UTF-8 there is a real failure).

importAttributes discriminator — re-probed both directions:

Flag import (extensionless .ts) require()
--conditions=require dep-ok dep-ok
--conditions=import dep-ok dep-ok

require() contexts never carry importAttributes; import contexts always do — including under the flags. Neither direction can be poisoned now.

Suite — 83/83 on v26.9.0 (sync path) and v24.11.1 (fallback) with a fresh binding. Note: pnpm run test resolves node through Vite+'s bundled runtime (24.x here), so it exercises the fallback — the direct vitest binary under fnm is what actually runs the sync path. vp check clean; 61 CI checks green, and the new node: 26 matrix entry means CI now covers the synchronous path for the first time.

The four new spec tests are the right ones — extensionless import, CJS→ESM flip, and require() of .ts under both injected conditions, plus .node and latin-1. Using the package's own binding as the .node fixture is a nice touch.

Remaining notes (non-blocking)

  • commonjs-typescript + source: null is still a contract violation in the abstract — it would produce ERR_INVALID_RETURN_PROPERTY_VALUE — but it's only reachable via import of .ts/.cts inside node_modules, which Node refuses on both paths anyway (ERR_UNSUPPORTED_NODE_MODULES_TYPE_STRIPPING on the old one). Dead-in-practice; if you ever want the shape airtight, exact-match === "commonjs" in the JS deferral (and == "commonjs" in the Rust guard) makes the invalid shape unreachable while losing nothing — a node_modules .ts would then get the informative node error instead of one blaming the hook. Not worth churning on.
  • .node as a dep main fails on both paths (addon format quirks) — pre-existing, unchanged by this PR.

LGTM.

@Brooooooklyn
Brooooooklyn merged commit ab4ac78 into oxc-project:main Sep 18, 2026
61 checks passed
@Brooooooklyn

Copy link
Copy Markdown
Member

Picked up the two remaining notes myself — "allow edits by maintainers" is off on this branch, so they're in cjnoname#1 against fix/register-hooks-sync (commit 6e730fe on fix/register-hooks-sync-followup); merge that, or cherry-pick, or flip the checkbox and I'll push it here.

1. .node under node_modules  (pre-existing, both hook paths)
   Node defaultLoad ─► {format:"addon", source:null}
   transform_output ─► source: None ──napi──► undefined ─► translateAddon: "Expected null … got undefined"
   fix: Some(Either4::D(Null)) — hand back the null Node.js gave us

2. commonjs-typescript  (this PR)
   startsWith("commonjs") ─► source:null ─► "Expected a string, an ArrayBuffer, or a TypedArray"
   fix: exact "commonjs" in register.mjs and the Rust non-UTF-8 guard; Node.js' own
        ERR_UNSUPPORTED_NODE_MODULES_TYPE_STRIPPING is what shows for a .ts dependency now

(1) surfaced while probing the sync path but reproduces on v22/v24 with --experimental-addon-modules and the async hooks too, so it's not a regression here — just the one shared code path both fixes touch.

Verified: 85/85 on v26.9.0 (sync, direct vitest — pnpm run test resolves Vite+'s bundled 24.x and only ever exercises the fallback) and v24.11.1; the new spec alone on 22.23.2 / 24.11.1 / 26.9.0 and with OXC_TRANSFORM_ALL=true; both tests red before, green after; clippy/fmt/vp check clean.

@Brooooooklyn

Copy link
Copy Markdown
Member

Opened #767 on the main repo: your three commits unchanged plus the two follow-ups (cjnoname#1), so the full matrix — including the new Linux node@26 jobs — runs on it. Either merge cjnoname#1 into this branch and I'll close #767, or we land #767 and it closes this one; your call, authorship stays yours in both.

Brooooooklyn added a commit that referenced this pull request Sep 18, 2026
…pendency and addon edge cases (#767)

Follow-up to #764 (now on `main`), for the notes left in its review. Two
commits.

## Summary

```
transform_output                                   register.mjs
  no-source arm ─► source: None ─► undefined        startsWith("commonjs") also matched
  addon translator asserts === null ─► throws         commonjs-typescript ─► null ─► throws
  fix: Some(Either4::D(Null))                        fix: === "commonjs" (JS + Rust guard)

LoadContext.format: Either<String, Null>
  Node passes format: undefined for an unknown extension (.node under
  node_modules, flag off) ─► napi "Missing field `format`"
  fix: Option<…> so Node's own ERR_UNKNOWN_FILE_EXTENSION shows
```

The first and third are pre-existing on both hook paths (shared code);
only the `startsWith` came in with #764.

**Tests** — `register-hooks.spec.ts`, 20 cases: local `.node` import
(with and without `--experimental-addon-modules`), `require()` of a
`.node`, latin-1; dependencies × `OXC_TRANSFORM_ALL` false/true set
in-spec: addon with the flag, addon following Node's default (loads on ≥
24.19 / ≥ 26.5, `ERR_UNKNOWN_FILE_EXTENSION` otherwise), latin-1, `.cts`
and `.mts` → Node's own `ERR_UNSUPPORTED_NODE_MODULES_TYPE_STRIPPING`;
`--conditions=require|import` in both directions. Failing cases also
assert neither `ERR_INVALID_RETURN_PROPERTY_VALUE` nor `Missing field`
appears.

**CI** — `test-linux-binding` gains Node 26 (armv7 excluded:
`node:26-slim` ships no arm/v7 image, same as 24), so the synchronous
path runs on every OS in the matrix.

#### Test plan
- [x] Full suite 95/95 on v22.23.2, v24.11.1, v26.9.0 (direct `vitest`;
local `pnpm test` resolves Vite+'s bundled 24.x and only exercises the
fallback)
- [x] Spec alone on v24.21.0 (default-on addon branch on the 24 line)
and with an outer `OXC_TRANSFORM_ALL=true`
- [x] Each new test red before its fix, green after
- [x] Rebased onto `main` after #764 landed; `cargo clippy -D warnings`,
`cargo fmt --check`, `cargo test`, `vp check`
- [ ] CI green across the 22/24/26 × transform-all matrix on all hosts

Generated with [Devin](https://devin.ai)

---------

Co-authored-by: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
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