Skip to content

fix: emit ESM imports for injected helpers in ES modules - #743

Merged
Brooooooklyn merged 4 commits into
oxc-project:mainfrom
cjnoname:fix/esm-helper-imports
Sep 12, 2026
Merged

Brooooooklyn merged 4 commits into
oxc-project:mainfrom
cjnoname:fix/esm-helper-imports

Conversation

@cjnoname

@cjnoname cjnoname commented Sep 2, 2026

Copy link
Copy Markdown
Contributor

Summary

When a transform needs a runtime helper, oxc injects an import of it and picks import or require() based on the module kind of the source. For .js, .jsx, .ts and .tsx that module kind is ModuleKind::Unambiguous, so oxc infers it from the presence of import/export syntax — and a file that happens to have none is treated as a script, which gets require().

Node.js decides from the nearest package.json instead. A file with no module syntax inside a "type": "module" package is therefore executed as an ES module with a require() call in it:

// package.json
{ "type": "module" }
// a.ts — no import or export anywhere in the file
class Base {
  set field(value: unknown) {
    (this as any).setterCalled = true;
  }
}
class Derived extends Base {
  field = 1;
}
console.log(new Derived().field);
$ node --import @oxc-node/core/register ./a.ts
var _defineProperty = require("@oxc-node/core/helpers/defineProperty");
                      ^
ReferenceError: require is not defined in ES module scope, you can use import instead

Any transform that needs a helper hits this — lowered class fields, decorators, using, object rest/spread, and so on — as long as the file itself has no import or export.

Fix

The load hook already knows which it is, because Node.js reports the format, so it passes that down and the parser is told the module kind outright instead of guessing from the syntax:

let source_type = SourceType::from_path(src_path).unwrap_or_default().with_module(is_es_module);

The pirates hook and the public transform() API keep inferring it: both target CommonJS, where require() is the correct output.

Only files that have no module syntax at all change — anything with an import or export was already resolved to an ES module by the parser, and with_module(false) is a no-op.

Verification

packages/integrate-vitest/__tests__/helper-imports.spec.ts covers both module kinds, with and without module syntax:

package type entry module syntax before after
module .ts none ReferenceError ok
module .mts none ok ok
module .ts has export ok ok
commonjs .ts none ok ok
commonjs .cts none ok ok

plus an imported (rather than entry-point) module with no module syntax.

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

Note

#742 touches oxc_transform a few lines away. The two are logically independent; whichever lands second needs a trivial rebase.

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

Brooooooklyn pushed a commit that referenced this pull request Sep 12, 2026
## Summary

`useDefineForClassFields` selects `[[Define]]` semantics for public
class fields: the field is installed with `Object.defineProperty`, so a
setter inherited from a base class is **not** called. Oxc's
`setPublicClassFields` assumption selects the opposite — `[[Set]]`
semantics, a plain assignment — so it has to be the *negation* of the
tsconfig option.

It was assigned it directly, which inverts every explicit setting:

```rust
assumptions: CompilerAssumptions {
    set_public_class_fields: use_define_for_class_fields,   // inverted
    ...
},
```

`useDefineForClassFields: false` is what NestJS, MobX, TypeORM and every
other decorator/DI based framework asks for, and oxc-node was doing the
exact opposite of it.

The default was wrong too. It came from `unwrap_or_default()`, i.e.
always `false`, ignoring `target`. TypeScript defaults the option to
`true` from `target: ES2022` — the first target with native class fields
— and to `false` below that. An unset `target` also means `true`: since
TypeScript 6, `tsc` defaults the target to the stable ECMAScript version
preceding `ESNext` (before TypeScript 6 it defaulted to `ES5`, which
meant `false`; `target: es5` itself was removed in TypeScript 7).

Two companion behaviours of `[[Set]]` mode come along for fidelity:

- `tsc` drops class fields without an initializer instead of assigning
`undefined` through the prototype chain (which would fire an inherited
setter). Oxc only does that under `removeClassFieldsWithoutInitializer`,
whose own documentation says to pair it with `setPublicClassFields` — so
the two are now set together.
- The class-properties `loose` option stays `false`: besides public
fields it also lowers `#private` fields to string-keyed properties,
leaking their names and freezing them with `Object.freeze` — something
`tsc` never does. The assumption alone selects `[[Set]]` for public
fields (the transformer ORs the two), so `loose` was never needed for
the fix.

## Verification

Ground truth is `tsc` from this repository's own devDependencies (7.0.2
at the time of writing), compiling

```ts
class Base {
  set x(value: unknown) {
    (this as any).setterCalled = true;
  }
}
export class Derived extends Base {
  x = 1;
}
```

| `target` | `useDefineForClassFields` | `tsc` | before | after |
| --- | --- | --- | --- | --- |
| ES2022 | unset | `[[Define]]` | `[[Define]]` | `[[Define]]` |
| ES2022 | `true` | `[[Define]]` | **`[[Set]]`** | `[[Define]]` |
| ES2022 | `false` | `[[Set]]` | **`[[Define]]`** | `[[Set]]` |
| ESNext | unset | `[[Define]]` | `[[Define]]` | `[[Define]]` |
| ESNext | `true` | `[[Define]]` | **`[[Set]]`** | `[[Define]]` |
| ESNext | `false` | `[[Set]]` | **`[[Define]]`** | `[[Set]]` |
| ES2017 | unset | `[[Set]]` | **`[[Define]]`** | `[[Set]]` |
| ES2017 | `true` | `[[Define]]` | **`[[Set]]`** | `[[Define]]` |
| ES2017 | `false` | `[[Set]]` | **`[[Define]]`** | `[[Set]]` |
| ES5 | unset | `[[Set]]` (TS≤5 config; `tsc` 7 rejects `es5` outright)
| **`[[Define]]`** | `[[Set]]` |
| ES5 | `true` | `[[Define]]` (ditto) | **`[[Set]]`** | `[[Define]]` |
| ES5 | `false` | `[[Set]]` (ditto) | **`[[Define]]`** | `[[Set]]` |
| *(none)* | unset | `[[Define]]` (TS≥6 default target) | `[[Define]]` |
`[[Define]]` |

All thirteen combinations now agree with `tsc`. Fields without an
initializer were checked too: `tsc` elides them under `[[Set]]` and
defines them as `undefined` under `[[Define]]`, legacy decorators on
elided fields still run, and `#private` fields stay private and mutation
survives `Object.freeze` under both — all pinned by the new spec.

`packages/integrate-vitest/__tests__/class-fields.spec.ts` pins each row
by observing whether the inherited setter runs, so the direction is
asserted rather than just "the two settings differ".

`integrate-module` (21/21), `integrate-module-bundler` (22/22 — it sets
`useDefineForClassFields: false` and exercises NestJS decorators and DI)
and `integrate-vitest` (55/55, 19 of them the new class-fields specs)
pass with `OXC_TRANSFORM_ALL` both set and unset. `cargo clippy
--all-targets --all-features -- -D warnings`, `cargo fmt --check` and
`vp check` are clean. No napi surface change, so the generated bindings
are untouched.

## Compatibility

This changes the emitted semantics for any project that set
`useDefineForClassFields` explicitly, or that targets below ES2022 — in
every case to what `tsc` produces for the same tsconfig, which is the
point.

## Note

This branch is rebased on current `main` (past #569's per-file tsconfig
discovery, which changed the `oxc_transform` signature this code
touches). #743 touches `oxc_transform` a few lines away and still
applies on its own; its `helper-imports` fixture uses `target: ES2022`
with the option unset, which is `[[Define]]` both before and after this
change, so the two remain independent of merge order.
cjnoname and others added 3 commits September 12, 2026 17:21
When a transform needs a runtime helper, oxc injects an import of it and picks
`import` or `require()` from the module kind of the source. For `.js`, `.jsx`,
`.ts` and `.tsx` that module kind is `Unambiguous`, so oxc infers it from the
presence of `import`/`export` syntax — and a file that happens to have none is
treated as a script, which gets `require()`.

Node.js decides from the nearest `package.json` instead. A file with no module
syntax inside a `"type": "module"` package is therefore executed as an ES module
with a `require()` call in it:

    // pkg/package.json: { "type": "module" }
    // pkg/a.ts — no import or export anywhere
    class Base { set field(value) { this.setterCalled = true; } }
    class Derived extends Base { field = 1; }

    ReferenceError: require is not defined in ES module scope

The `load` hook already knows which it is, because Node.js reports the format, so
it now passes that down and the parser is told the module kind outright instead of
guessing from the syntax. The `pirates` hook and the public `transform()` API keep
inferring it: both target CommonJS, where `require()` is correct.

Only files that have no module syntax at all change; anything with an `import` or
`export` was already resolved to an ES module by the parser.
The fixture forced a helper with `useDefineForClassFields: false`, which is
`[[Define]]` under the current mapping but `[[Set]]` once oxc-project#742 corrects it — and
`[[Set]]` needs no helper at all, so the fixture would stop testing anything and
the assertion would fail.

`target: ES2022` with the option left unset is `[[Define]]` both ways: it is
TypeScript's own default for that target, and it is what `unwrap_or_default()`
produces today. Verified against both builds.
Two problems in the fixture, no product change:

- `--import` takes a module specifier, not a path. An absolute path only
  parses on POSIX; on Windows the drive letter is read as a URL scheme and
  Node.js exits with ERR_UNSUPPORTED_ESM_URL_SCHEME before the hooks are
  registered, failing every case in the Windows CI matrix. Hand it the
  file URL's href instead.

- For `.ts` the loader asks the file's own tsconfig for the module kind
  before falling back to the package.json `type`, so with
  `module: "ESNext"` the "commonjs" row loaded as an ES module — it
  went red without the fix, proving it never exercised CommonJS. Each row
  now gets a tsconfig that agrees with its package type.

Verified by running the spec against a binding built from main's
src/lib.rs: the two genuine ES-module cases still fail with the original
ReferenceError while the CommonJS rows pass; against the fixed binding all
six pass.
`TS_NODE_PROJECT` and `OXC_TSCONFIG_PATH` win over every fixture's own
tsconfig.json, and the child inherited them from the test runner's
environment. An override with `useDefineForClassFields: false` turns
helper injection off entirely, and one with `module: "ESNext"` flips the
CommonJS row back to ESM — either way the matrix silently passes against
a binding that still has the bug. Clear both; `OXC_TRANSFORM_ALL` stays
because CI sets it deliberately.

Each row now also prints the module kind it actually executed as
(`typeof require` — defined only in a CommonJS scope) and the assertion
checks it against the row, so a silent format flip fails the spec instead
of passing under the wrong kind.

Verified against a binding built from main's src/lib.rs, with a hostile
`OXC_TSCONFIG_PATH` in the environment: the two genuine ES-module cases
still fail with the original ReferenceError. The same fixture without the
env clearing prints `field: 1` and exits 0.
@Brooooooklyn

Copy link
Copy Markdown
Member

Deep review + polish pass, pushed as two commits on the rebased branch.

Rebase. #742 landed first, so the branch is now rebased onto main — the "trivial rebase" from the note above is done and was in fact clean (the class-field decoupling in 1efc9ca holds under the shipped polarity; verified against the post-#742 build).

The product change is correct and minimal. In oxc, SourceType::with_module(false) is a literal no-op (if yes { module_kind = Module }), so the only behaviour that changes is for files Node.js reports as format: "module" — exactly the broken case. The pirates hook and transform() pass false, so the CommonJS paths are byte-identical to before.

Two fixture defects found by adversarial review, both verified by reproduction before fixing:

  1. Windows CI would have failed all six cases. --import was given fileURLToPath(...) — an absolute path. That parses only on POSIX; on Windows the drive letter is read as a URL scheme and Node.js exits with ERR_UNSUPPORTED_ESM_URL_SCHEME before the hooks register (reproduced the mechanism with a c: path). Now passes the file URL's href.
  2. The "commonjs package / entry.ts" row never ran as CommonJS. For .ts, the loader consults the file's own tsconfig module before the package.json type, so module: "ESNext" loaded that row as an ES module — proof: it went red without the fix. Each row now gets a tsconfig that agrees with its package type, and every entry prints the module kind it actually executed as (typeof require) so a silent format flip fails the spec instead of passing under the wrong kind.

A second review round found one more: the spawned fixtures inherited TS_NODE_PROJECT / OXC_TSCONFIG_PATH, which override every fixture's tsconfig — an override with useDefineForClassFields: false turns helper injection off and the spec passes against a broken binding (reproduced: field: 1, exit 0, on the unfixed build). Both are cleared now; OXC_TRANSFORM_ALL still passes through.

Verification (macOS arm64, Node.js 24.21.0):

  • Spec against a binding built from main's src/lib.rs: the two genuine ES-module cases fail with the original ReferenceError, CommonJS rows pass — the spec is not vacuous, including under a hostile OXC_TSCONFIG_PATH.
  • Full pnpm test with OXC_TRANSFORM_ALL both set and unset: integrate-module 21/21, integrate-module-bundler 22/22, integrate-vitest 61/61.
  • cargo clippy --all-targets --all-features -- -D warnings, cargo fmt --check, vp fmt --check, vp lint: clean.

Out of scope, noted for a separate issue: a .ts entry point with ESM syntax inside a "type": "commonjs" package fails with ERR_REQUIRE_CYCLE_MODULE via the pirates require path. Reproduced identically on main's binding, so it is pre-existing and untouched by this PR.

@Brooooooklyn
Brooooooklyn merged commit bf94957 into oxc-project:main Sep 12, 2026
53 checks passed
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