Repository navigation
fix: emit ESM imports for injected helpers in ES modules - #743
Conversation
|
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 No product code changed for this — only the two fixtures. |
## 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.
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.
260fb3e to
be2272a
Compare
`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.
|
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, Two fixture defects found by adversarial review, both verified by reproduction before fixing:
A second review round found one more: the spawned fixtures inherited Verification (macOS arm64, Node.js 24.21.0):
Out of scope, noted for a separate issue: a |
Summary
When a transform needs a runtime helper, oxc injects an import of it and picks
importorrequire()based on the module kind of the source. For.js,.jsx,.tsand.tsxthat module kind isModuleKind::Unambiguous, so oxc infers it from the presence ofimport/exportsyntax — and a file that happens to have none is treated as a script, which getsrequire().Node.js decides from the nearest
package.jsoninstead. A file with no module syntax inside a"type": "module"package is therefore executed as an ES module with arequire()call in it: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 noimportorexport.Fix
The
loadhook 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:The
pirateshook and the publictransform()API keep inferring it: both target CommonJS, whererequire()is the correct output.Only files that have no module syntax at all change — anything with an
importorexportwas already resolved to an ES module by the parser, andwith_module(false)is a no-op.Verification
packages/integrate-vitest/__tests__/helper-imports.spec.tscovers both module kinds, with and without module syntax:typemodule.tsReferenceErrormodule.mtsmodule.tsexportcommonjs.tscommonjs.ctsplus an imported (rather than entry-point) module with no module syntax.
integrate-module(21/21),integrate-module-bundler(22/22) andintegrate-vitestpass on Node.js 22.23.2 and 24.20.0 withOXC_TRANSFORM_ALLboth set and unset.cargo clippy --all-targets --all-features -- -D warningsandpnpm lintare clean. No napi surface change, so the generated bindings are untouched.Note
#742 touches
oxc_transforma few lines away. The two are logically independent; whichever lands second needs a trivial rebase.