fix(transformer/class-properties): use correct property name when converting parameter properties (#21268)

## Summary

Fixes #21116 where TypeScript constructor parameter properties emit the
wrong property name when the parameter name clashes with a free variable
referenced in a class field initializer.

## Problem

When targeting pre-ES2022 (or with `transform-class-properties`
enabled), the class properties plugin detects that a constructor
parameter name shadows an outer variable referenced in a field
initializer, and renames the parameter binding (e.g. `x` → `_x`) to
preserve the initializer's semantics.

`convert_constructor_params` (TypeScript plugin) ran after this rename
and read `id.name` — which had already been mutated — to produce the
`this.X = X` assignment. This caused:

```ts
let x = 10;
class Foo {
  field = x;
  constructor(public x: number) {}
}
```

to incorrectly emit:

```js
constructor(_x) {
  this._x = _x; // ❌ property name should be x
  ...
}
```

## Root Cause

Both plugins share a single traversal pass with interleaved hooks.
`enter_class_body` (class properties) fires before
`enter_method_definition` (TypeScript), so by the time
`convert_constructor_params` runs, `BindingIdentifier.name` has already
been mutated by `ConstructorSymbolRenamer`.

## Fix

Recover the property name from `id.span.source_text(source_text)`
instead of `id.name`. Spans are set by the parser and never mutated by
any transformer, so this always returns the original user-written
identifier regardless of renames.

```ts
constructor(_x) {
  this.x = _x; // ✅
  ...
}
```

## AI Disclosure

This fix was developed with Claude Code assistance.

Co-authored-by: Dunqing <dengqing0821@gmail.com>
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
authored and GitHub committed 2026-04-13 10:57:24 +08:00
1 parent 7888280598
commit af1a586501
5 files changed
+29 -3

No files matched your search

@@ -2,6 +2,7 @@ use oxc_allocator::{TakeIn, Vec as ArenaVec};
use oxc_ast::ast::*;
use oxc_semantic::ScopeFlags;
use oxc_span::SPAN;
use oxc_str::Ident;
use oxc_syntax::operator::AssignmentOperator;
use oxc_traverse::BoundIdentifier;
@@ -381,12 +382,15 @@ impl<'a> TypeScript<'a> {
params: &ArenaVec<'a, FormalParameter<'a>>,
ctx: &mut TraverseCtx<'a>,
) -> impl Iterator<Item = Statement<'a>> {
let source_text = ctx.state.source_text;
params
.iter()
.filter(|param| param.has_modifier())
.filter_map(|param| param.pattern.get_binding_identifier())
.map(|id| {
let target = create_this_property_assignment(id.span, id.name, ctx);
// `id.name` may be renamed by clash detection; use span to recover the original source name.
let prop_name = Ident::from(id.span.source_text(source_text));
let target = create_this_property_assignment(id.span, prop_name, ctx);
let value = BoundIdentifier::from_binding_ident(id).create_read_expression(ctx);
Self::create_assignment(target, value, ctx)
})
@@ -1,6 +1,6 @@
commit: 91b4ce32
Passed: 210/347
Passed: 211/348
# All Passed:
* babel-plugin-transform-class-static-block
@@ -69,7 +69,7 @@ after transform: SymbolId(0): [ReferenceId(0), ReferenceId(2), ReferenceId(6), R
rebuilt : SymbolId(0): [ReferenceId(0), ReferenceId(2), ReferenceId(6), ReferenceId(10)]
# babel-plugin-transform-typescript (7/31)
# babel-plugin-transform-typescript (8/32)
* allow-declare-fields-false/input.ts
Unresolved references mismatch:
after transform: ["dce"]
@@ -0,0 +1,9 @@
// A free reference in a field initializer resolves to an outer variable with the
// same name as a constructor parameter property. The constructor parameter gets
// renamed to avoid shadowing the outer variable once the initializer moves into
// the constructor body. `this.x` must still use the original source name, not `_x`.
let x = 10;
class Foo {
field = x;
constructor(public x: number) {}
}
@@ -0,0 +1,6 @@
{
"plugins": [
"transform-class-properties",
"transform-typescript"
]
}
@@ -0,0 +1,7 @@
let x = 10;
class Foo {
constructor(_x) {
this.x = _x;
babelHelpers.defineProperty(this, "field", x);
}
}