fix(transformer/typescript): inline optional-chain enum member access (#21834)

## Summary

Closes #21813.
Closes #21828.

`c_num?.x` and `c_num?.['x']` were not handled by the enum-inlining pass in `enter_expression`, which only matched `Expression::StaticMemberExpression` and `Expression::ComputedMemberExpression`. When the es2020 optional-chain plugin lowered the chain first, the member access became:

```js
c_num === null || c_num === void 0 ? void 0 : c_num.x
```

Then the inline pass saw the inner `c_num.x` and replaced it with the constant + deleted *that one* reference — but the two references in the test condition stayed live. With `optimizeConstEnums: true`, the const-enum declaration is then unconditionally removed, leaving dangling references to a binding that no longer exists.

The same shape underlies the rolldown report ([rolldown/rolldown#9181](https://github.com/rolldown/rolldown/issues/9181)) where rolldown's cross-module enum inlining drops the import that `c_num` was bound to.

## Fix

Match `Expression::ChainExpression` whose top element is an optional `StaticMemberExpression` or `ComputedMemberExpression` on an enum identifier, and inline the entire chain to the constant **before** es2020 sees it. Reuses the existing `try_inline_enum_member` / `try_inline_computed_enum_member` helpers — they don't care about the `optional` flag.

## Fixtures

- `optimize-enums/optional-chain-issue-21813/` — the bug case + an `exec.ts` that runs the transformed output to verify runtime semantics
- `optimize-enums/optional-chain-value-kept/` — IIFE preserved when a bare `Foo` ref also exists
- `optimize-enums/optional-chain-deeper-access/` — `Foo?.x.toString()` / `Bar?.['y']?.toString()` exercise nested chains where the inner inline still fires via the existing arms
- `const-enum-optional-chain/` — const-enum path with `optimizeConstEnums: true`

## Known limitation

Deeper chains under es2020 lowering — `c_num?.x.y` or `c_num?.x()` where the optional access is below the chain element — still produce dangling refs because the chain element is non-optional or a `CallExpression` and this fix's match arm doesn't trigger. Without es2020 lowering they work (the inner `?.x` gets visited as a regular `Expression::StaticMemberExpression`), so the conformance fixtures pass. A more general fix would walk the chain to find the leftmost optional-on-enum access; out of scope for this PR.

## Test plan

- [x] `cargo run -p oxc_transform_conformance -- --filter optimize-enums/optional-chain` — all three pass
- [x] `cargo run -p oxc_transform_conformance -- --filter const-enum-optional-chain` — passes
- [x] `cargo run -p oxc_transform_conformance -- --exec --filter optimize-enums/optional-chain-issue-21813` — runtime check passes
- [x] `cargo test -p oxc_transformer` — green
- [x] `cargo clippy -p oxc_transformer -- -D warnings` — clean
- [x] CI Conformance — green (snapshots updated for `oxc.snap.md` + `oxc_exec.snap.md`)
This commit is contained in:
Dunqing committed 2026-05-07 09:04:41 +00:00
1 parent 9414bee797
commit 0c7c01c63b
14 files changed
+91 -25

No files matched your search

+25 -20
View File
@@ -11,7 +11,7 @@ use oxc_syntax::{
constant_value::ConstantValue,
number::NumberBase,
operator::{AssignmentOperator, BinaryOperator, LogicalOperator, UnaryOperator},
reference::ReferenceFlags,
reference::{ReferenceFlags, ReferenceId},
symbol::SymbolFlags,
};
use oxc_traverse::{BoundIdentifier, Traverse};
@@ -102,28 +102,31 @@ impl<'a> Traverse<'a, TransformState<'a>> for TypeScriptEnum {
return;
}
let (value, object_ref_id) = match expr {
let inlined = match expr {
Expression::StaticMemberExpression(member_expr) => {
let ref_id = member_expr
.object
.get_identifier_reference()
.and_then(|i| i.reference_id.get());
(self.try_inline_enum_member(member_expr, ctx), ref_id)
self.try_inline_enum_member(member_expr, ctx)
}
Expression::ComputedMemberExpression(member_expr) => {
let ref_id = member_expr
.object
.get_identifier_reference()
.and_then(|i| i.reference_id.get());
(self.try_inline_computed_enum_member(member_expr, ctx), ref_id)
self.try_inline_computed_enum_member(member_expr, ctx)
}
// `c_num?.x` / `c_num?.['x']`: inline before es2020 lowers the chain.
// Otherwise lowering produces `c_num === null || c_num === void 0 ? void 0 : c_num.x`,
// and only the inner `c_num.x` reference gets deleted by the inline pass — leaving
// the test-condition references dangling once the enum declaration is removed.
Expression::ChainExpression(chain_expr) => match &chain_expr.expression {
ChainElement::StaticMemberExpression(member_expr) if member_expr.optional => {
self.try_inline_enum_member(member_expr, ctx)
}
ChainElement::ComputedMemberExpression(member_expr) if member_expr.optional => {
self.try_inline_computed_enum_member(member_expr, ctx)
}
_ => return,
},
_ => return,
};
if let Some(value) = value {
if let Some(ref_id) = object_ref_id {
ctx.scoping_mut().delete_reference(ref_id);
}
if let Some((value, ref_id)) = inlined {
ctx.scoping_mut().delete_reference(ref_id);
*expr = match value {
ConstantValue::Number(n) => Self::get_initializer_expr(n, ctx),
ConstantValue::String(s) => {
@@ -465,11 +468,13 @@ impl<'a> TypeScriptEnum {
}
/// Try to inline `Direction.Up` to its literal value.
/// Returns the constant and the `ReferenceId` of the enum identifier on the LHS,
/// so the caller can delete the now-unused reference.
fn try_inline_enum_member(
&self,
expr: &StaticMemberExpression<'a>,
ctx: &TraverseCtx<'a>,
) -> Option<ConstantValue> {
) -> Option<(ConstantValue, ReferenceId)> {
let Expression::Identifier(ident) = &expr.object else { return None };
self.resolve_enum_member(ident, expr.property.name.as_str(), ctx)
}
@@ -479,7 +484,7 @@ impl<'a> TypeScriptEnum {
&self,
expr: &ComputedMemberExpression<'a>,
ctx: &TraverseCtx<'a>,
) -> Option<ConstantValue> {
) -> Option<(ConstantValue, ReferenceId)> {
let Expression::Identifier(ident) = &expr.object else { return None };
let Expression::StringLiteral(prop) = &expr.expression else { return None };
self.resolve_enum_member(ident, prop.value.as_str(), ctx)
@@ -493,7 +498,7 @@ impl<'a> TypeScriptEnum {
ident: &IdentifierReference<'a>,
property_name: &str,
ctx: &TraverseCtx<'a>,
) -> Option<ConstantValue> {
) -> Option<(ConstantValue, ReferenceId)> {
let ref_id = ident.reference_id.get()?;
let symbol_id = ctx.scoping().get_reference(ref_id).symbol_id()?;
@@ -510,7 +515,7 @@ impl<'a> TypeScriptEnum {
ctx.scoping().get_binding(body_scope_id, property_name.into())
&& let Some(value) = ctx.scoping().get_enum_member_value(member_symbol_id)
{
return Some(value.clone());
return Some((value.clone(), ref_id));
}
}
None
@@ -1,12 +1,12 @@
File | File size || Sys allocs | Sys reallocs || Arena allocs | Arena reallocs | Arena bytes
-------------------------------------------------------------------------------------------------------------------------------------------
checker.ts | 2.92 MB || 18642 | 584 || 1082129 | 118667
checker.ts | 2.92 MB || 18642 | 584 || 1082132 | 118667
cal.com.tsx | 1.06 MB || 14393 | 787 || 532380 | 57908
RadixUIAdoptionSection.jsx | 2.52 kB || 54 | 37 || 1172 | 157
pdf.mjs | 567.30 kB || 15308 | 356 || 442034 | 43489
pdf.mjs | 567.30 kB || 15308 | 356 || 442227 | 43489
antd.js | 6.69 MB || 83239 | 4671 || 2638108 | 302458
@@ -1,6 +1,6 @@
commit: 6402dbbf
Passed: 225/372
Passed: 228/376
# 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 (19/52)
# babel-plugin-transform-typescript (22/56)
* allow-declare-fields-false/input.ts
Unresolved references mismatch:
after transform: ["dce"]
@@ -490,6 +490,17 @@ Symbol flags mismatch for "Runtime":
after transform: SymbolId(0): SymbolFlags(RegularEnum)
rebuilt : SymbolId(0): SymbolFlags(FunctionScopedVariable)
* optimize-enums/optional-chain-value-kept/input.ts
Bindings mismatch:
after transform: ScopeId(1): ["A", "B", "Foo"]
rebuilt : ScopeId(1): ["Foo"]
Scope flags mismatch:
after transform: ScopeId(1): ScopeFlags(0x0)
rebuilt : ScopeId(1): ScopeFlags(Function)
Symbol flags mismatch for "Foo":
after transform: SymbolId(0): SymbolFlags(RegularEnum)
rebuilt : SymbolId(0): SymbolFlags(FunctionScopedVariable)
* optimize-enums/passed-as-argument-kept/input.ts
Bindings mismatch:
after transform: ScopeId(1): ["Active", "Inactive", "Status"]
@@ -2,7 +2,7 @@ commit: 6402dbbf
node: v24.14.0
Passed: 13 of 15 (86.67%)
Passed: 14 of 16 (87.50%)
Failures:
@@ -0,0 +1,7 @@
const enum Theme {
Light = "Light",
Dark = "Dark",
}
console.log(Theme?.Light);
console.log(Theme?.["Dark"]);
@@ -0,0 +1,3 @@
{
"plugins": [["transform-typescript", { "optimizeConstEnums": true }]]
}
@@ -0,0 +1,2 @@
console.log("Light");
console.log("Dark");
@@ -0,0 +1,7 @@
enum Foo { x = 1 }
enum Bar { y = 2 }
// `Foo?.x` followed by another property access — the inner `?.x` reference
// must still be inlined and the enum removed.
console.log(Foo?.x.toString());
console.log(Bar?.['y']?.toString());
@@ -0,0 +1,2 @@
console.log(1 .toString());
console.log(2?.toString());
@@ -0,0 +1,5 @@
// https://github.com/oxc-project/oxc/issues/21813
enum c_num { x = 123 }
enum d_num { x = 123 }
expect([c_num?.x, d_num?.['x']]).toEqual([123, 123]);
@@ -0,0 +1,7 @@
enum c_num { x = 123 }
enum d_num { x = 123 }
console.log([
c_num?.x,
d_num?.['x'],
]);
@@ -0,0 +1,8 @@
enum Foo {
A = 1,
B = 2,
}
Foo?.A;
Foo?.["B"];
console.log(Foo);
@@ -0,0 +1,8 @@
var Foo = /* @__PURE__ */ function(Foo) {
Foo[Foo["A"] = 1] = "A";
Foo[Foo["B"] = 2] = "B";
return Foo;
}(Foo || {});
1;
2;
console.log(Foo);