Skip to content

fix: hand Node.js' null source back as null; keep commonjs-typescript off the deferral - #1

Closed
Brooooooklyn wants to merge 3 commits into
cjnoname:fix/register-hooks-syncfrom
oxc-project:fix/register-hooks-sync-followup
Closed

Brooooooklyn wants to merge 3 commits into
cjnoname:fix/register-hooks-syncfrom
oxc-project:fix/register-hooks-sync-followup

Conversation

@Brooooooklyn

Copy link
Copy Markdown

Follow-up for oxc-project#764 — the two remaining notes from the review, as a PR into your branch since "allow edits by maintainers" is off.

Summary

  • transform_output: the null source Node.js hands over (addon for a .node under node_modules) now goes back as null, not a dropped Optionundefined, which translateAddon rejects. Pre-existing on both hook paths.
  • register.mjs + the non-UTF-8 guard: exact commonjs match instead of startsWith, so commonjs-typescript keeps its source and Node.js reports ERR_UNSUPPORTED_NODE_MODULES_TYPE_STRIPPING itself instead of ERR_INVALID_RETURN_PROPERTY_VALUE.
  • Two tests in register-hooks.spec.ts (spawn helper split out of run for the failing-exit case).

Test plan

  • 85/85 on v26.9.0 (sync path, direct vitest) and v24.11.1 (fallback)
  • register-hooks.spec.ts alone on 22.23.2 / 24.11.1 / 26.9.0, and 26 with OXC_TRANSFORM_ALL=true
  • both new tests red before, green after
  • cargo clippy -D warnings, cargo fmt --check, vp check

Generated with Devin

## Problem

`packages/core/register.mjs` calls `module.register()`. Node.js
runtime-deprecated that
API in **v25.9.0**
([DEP0205](https://nodejs.org/api/deprecations.html#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](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](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 | 70 passed` |
| 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.

---------

Co-authored-by: LongYinan <lynweklm@gmail.com>
@Brooooooklyn

Copy link
Copy Markdown
Author

Pushed a second commit with edge-case coverage and one more fix it flushed out:

  • LoadContext.format is now optional. Node.js passes format: undefined when the resolve chain reported none (a .node under node_modules without --experimental-addon-modules, i.e. Node 22 and <24.19), and the required field made napi reject the whole context with Missing field \format`where plain Node raisesERR_UNKNOWN_FILE_EXTENSION. Pre-existing on both hook paths; index.d.ts regenerated (format?: string | null`).
  • register-hooks.spec.ts: 20 tests. Local: .node import, .node import with the flag, require() of a .node, latin-1. Dependency (× OXC_TRANSFORM_ALL false/true, set explicitly 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. Every failing case also asserts neither ERR_INVALID_RETURN_PROPERTY_VALUE nor Missing field appears.
  • test-linux-binding matrix gains Node 26 (armv7 excluded — node:26-slim has no arm/v7 image, same as 24).

Verified: 95/95 full suite on 22.23.2 / 24.11.1 / 26.9.0; spec alone additionally on 24.21.0 (the default-on branch on the 24 line) and with an outer OXC_TRANSFORM_ALL=true; the addon-default test is red on 22 without the LoadContext change; cargo clippy -D warnings, cargo test, vp check clean.

Brooooooklyn and others added 2 commits September 18, 2026 15:01
…ipt off the deferral

Node.js' `addon` translator asserts `source === null` for a `.node` file
under node_modules, but `transform_output` dropped the `Option` and the
result reached JavaScript as `undefined`, on both the asynchronous and
the synchronous hooks. The `startsWith("commonjs")` deferral also caught
`commonjs-typescript`, whose translator needs the source, turning Node's
own type-stripping error into one blaming the hook.

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

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
…very matrix runtime

Node.js hands the load hook `format: undefined` when the resolve chain
reported none — a `.node` under node_modules without its flag — and the
required `LoadContext.format` rejected the whole context with "Missing
field `format`" where Node.js would have raised ERR_UNKNOWN_FILE_EXTENSION.
Make it optional so Node.js' own error is what shows.

The dependency cases (addon with and without the flag, latin-1, `.cts`,
`.mts`) now run under both OXC_TRANSFORM_ALL values in-spec, with the
addon default keyed on the runtime (on from v24.19.0 / v26.5.0), and the
Linux matrix gains Node.js 26 so the synchronous hooks are exercised there
as well as on macOS and Windows.

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

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
@Brooooooklyn
Brooooooklyn force-pushed the fix/register-hooks-sync-followup branch from ba923c2 to f8c9b60 Compare September 18, 2026 07:01
@Brooooooklyn

Copy link
Copy Markdown
Author

Superseded: oxc-project#764 landed on main and the follow-ups are now oxc-project#767 rebased on top of it.

@Brooooooklyn
Brooooooklyn deleted the fix/register-hooks-sync-followup branch September 18, 2026 07:54
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