mirror of
https://github.com/oxc-project/oxc.git
synced 2026-11-04 10:29:58 +00:00
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:
1 parent
9414bee797
commit
0c7c01c63b
14 files changed
+91
-25
No files matched your search
@@ -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:
|
||||
|
||||
|
||||
+7
@@ -0,0 +1,7 @@
|
||||
const enum Theme {
|
||||
Light = "Light",
|
||||
Dark = "Dark",
|
||||
}
|
||||
|
||||
console.log(Theme?.Light);
|
||||
console.log(Theme?.["Dark"]);
|
||||
+3
@@ -0,0 +1,3 @@
|
||||
{
|
||||
"plugins": [["transform-typescript", { "optimizeConstEnums": true }]]
|
||||
}
|
||||
+2
@@ -0,0 +1,2 @@
|
||||
console.log("Light");
|
||||
console.log("Dark");
|
||||
+7
@@ -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());
|
||||
+2
@@ -0,0 +1,2 @@
|
||||
console.log(1 .toString());
|
||||
console.log(2?.toString());
|
||||
+5
@@ -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]);
|
||||
+7
@@ -0,0 +1,7 @@
|
||||
enum c_num { x = 123 }
|
||||
enum d_num { x = 123 }
|
||||
|
||||
console.log([
|
||||
c_num?.x,
|
||||
d_num?.['x'],
|
||||
]);
|
||||
+1
@@ -0,0 +1 @@
|
||||
console.log([123, 123]);
|
||||
+8
@@ -0,0 +1,8 @@
|
||||
enum Foo {
|
||||
A = 1,
|
||||
B = 2,
|
||||
}
|
||||
|
||||
Foo?.A;
|
||||
Foo?.["B"];
|
||||
console.log(Foo);
|
||||
+8
@@ -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);
|
||||
Reference in new issue
Block a user