From 3fee92c156acc7c074436d78f125686e667e66a2 Mon Sep 17 00:00:00 2001 From: baltasarblanco Date: Wed, 5 Aug 2026 13:18:04 -0300 Subject: [PATCH 1/8] fix(es/typescript): Treat const variable references as enum constants `EnumValueComputer::compute_rec` gates its identifier arm on `ctxt == unresolved_ctxt`, which only matches enum member references. A reference to a real `const` binding fell through to `Opaque`, so members initialized from one were emitted as reverse mappings instead of string enum members, and numeric ones broke auto-increment: `inc()` returns `Void` for an opaque value, so every following member collapsed to `undefined`. Resolve references to `const` bindings whose initializer is a constant expression, following the syntactic rules TypeScript applies (see microsoft/TypeScript#50528). A binding qualifies when it is a simple non-ambient `const` with no type annotation and a constant initializer, transitively. Type syntax removes constness, and a binding declared after the enum is not visible to it. Closes #11715 --- .../src/semantic.rs | 38 ++++++- .../src/transform.rs | 7 +- .../src/ts_enum.rs | 106 ++++++++++++++---- .../fixture/issue-11715-const-var/input.ts | 20 ++++ .../fixture/issue-11715-const-var/output.js | 20 ++++ .../fixture/issue-11715-not-const/input.ts | 35 ++++++ .../fixture/issue-11715-not-const/output.js | 36 ++++++ .../tests/fixture/issue-11715-scope/input.ts | 30 +++++ .../tests/fixture/issue-11715-scope/output.js | 28 +++++ 9 files changed, 294 insertions(+), 26 deletions(-) create mode 100644 crates/swc_ecma_transforms_typescript/tests/fixture/issue-11715-const-var/input.ts create mode 100644 crates/swc_ecma_transforms_typescript/tests/fixture/issue-11715-const-var/output.js create mode 100644 crates/swc_ecma_transforms_typescript/tests/fixture/issue-11715-not-const/input.ts create mode 100644 crates/swc_ecma_transforms_typescript/tests/fixture/issue-11715-not-const/output.js create mode 100644 crates/swc_ecma_transforms_typescript/tests/fixture/issue-11715-scope/input.ts create mode 100644 crates/swc_ecma_transforms_typescript/tests/fixture/issue-11715-scope/output.js diff --git a/crates/swc_ecma_transforms_typescript/src/semantic.rs b/crates/swc_ecma_transforms_typescript/src/semantic.rs index 8d0e138c74ae..176b6ff71e7d 100644 --- a/crates/swc_ecma_transforms_typescript/src/semantic.rs +++ b/crates/swc_ecma_transforms_typescript/src/semantic.rs @@ -7,7 +7,7 @@ use swc_ecma_visit::{noop_visit_type, Visit, VisitWith}; use crate::{ retain::{should_retain_decl, IsConcrete}, shared::{enum_member_name, get_module_ident}, - ts_enum::{EnumValueComputer, TsEnumRecord, TsEnumRecordKey, TsEnumRecordValue}, + ts_enum::{EnumValueComputer, EvalCtx, TsEnumRecord, TsEnumRecordKey, TsEnumRecordValue}, }; #[derive(Debug, Default)] @@ -19,6 +19,7 @@ pub(crate) struct SemanticInfo { pub enum_record: TsEnumRecord, pub const_enum: FxHashSet, pub namespace_import_equals_usage: FxHashSet, + pub const_vars: FxHashMap, } impl SemanticInfo { @@ -257,6 +258,7 @@ impl SemanticAnalyzer { enum_id: &Id, default_init: &TsEnumRecordValue, record: &TsEnumRecord, + const_vars: &FxHashMap, unresolved_ctxt: SyntaxContext, flow_syntax: bool, ) -> TsEnumRecordValue { @@ -267,8 +269,9 @@ impl SemanticAnalyzer { enum_id, unresolved_ctxt, record, + const_vars, } - .compute(expr) + .compute(expr, EvalCtx::MEMBER) }) .filter(TsEnumRecordValue::has_value) .unwrap_or_else(|| { @@ -536,6 +539,34 @@ impl Visit for SemanticAnalyzer { } } + fn visit_var_decl(&mut self, node: &VarDecl) { + node.visit_children_with(self); + + if self.skip_transform_info || node.declare || node.kind != VarDeclKind::Const { + return; + } + + for decl in &node.decls { + let Pat::Ident(BindingIdent { id, type_ann: None }) = &decl.name else { + continue; + }; + let Some(init) = &decl.init else { continue }; + + let empty = TsEnumRecord::default(); + let value = EnumValueComputer { + enum_id: &id.to_id(), + unresolved_ctxt: self.unresolved_ctxt, + record: &empty, + const_vars: &self.info.const_vars, + } + .compute(init.clone(), EvalCtx::CONST_INIT); + + if value.is_const() { + self.info.const_vars.insert(id.to_id(), value); + } + } + } + fn visit_ts_enum_decl(&mut self, node: &TsEnumDecl) { node.visit_children_with(self); @@ -566,6 +597,7 @@ impl Visit for SemanticAnalyzer { &id.to_id(), &default_init, &self.info.enum_record, + &self.info.const_vars, self.unresolved_ctxt, self.flow_syntax, ); @@ -655,6 +687,7 @@ mod tests { &id("E"), &TsEnumRecordValue::Void, &Default::default(), + &Default::default(), SyntaxContext::empty(), true, ); @@ -672,6 +705,7 @@ mod tests { &id("E"), &TsEnumRecordValue::from(2.0), &Default::default(), + &Default::default(), SyntaxContext::empty(), false, ); diff --git a/crates/swc_ecma_transforms_typescript/src/transform.rs b/crates/swc_ecma_transforms_typescript/src/transform.rs index 0f530f21b19b..4edc59fe9b78 100644 --- a/crates/swc_ecma_transforms_typescript/src/transform.rs +++ b/crates/swc_ecma_transforms_typescript/src/transform.rs @@ -22,7 +22,9 @@ use crate::{ retain::{should_retain_module_item, should_retain_stmt}, semantic::SemanticInfo, shared::enum_member_name, - ts_enum::{static_enum_member_name, EnumValueComputer, TsEnumRecordKey, TsEnumRecordValue}, + ts_enum::{ + static_enum_member_name, EnumValueComputer, EvalCtx, TsEnumRecordKey, TsEnumRecordValue, + }, utils::{assign_value_to_this_private_prop, assign_value_to_this_prop, Factory}, }; @@ -1112,6 +1114,7 @@ impl Transform { enum_id: &id.to_id(), unresolved_ctxt: self.unresolved_ctxt, record: &self.semantic.enum_record, + const_vars: &self.semantic.const_vars, }; let member_list: Vec<_> = members @@ -1133,7 +1136,7 @@ impl Transform { // references can be rewritten to runtime property // accesses. Implicit Flow enum members do not have an // initializer, so keep the semantic value as-is. - let mut recomputed = enum_computer.compute(init); + let mut recomputed = enum_computer.compute(init, EvalCtx::RECOMPUTE); if let TsEnumRecordValue::Opaque(expr) = &mut recomputed { expr.visit_mut_with(&mut RefRewriter { query: EnumMemberRefQuery { diff --git a/crates/swc_ecma_transforms_typescript/src/ts_enum.rs b/crates/swc_ecma_transforms_typescript/src/ts_enum.rs index a05ef5139421..2aee76b6f227 100644 --- a/crates/swc_ecma_transforms_typescript/src/ts_enum.rs +++ b/crates/swc_ecma_transforms_typescript/src/ts_enum.rs @@ -97,6 +97,7 @@ pub(crate) struct EnumValueComputer<'a> { pub enum_id: &'a Id, pub unresolved_ctxt: SyntaxContext, pub record: &'a TsEnumRecord, + pub const_vars: &'a FxHashMap, } /// Returns a statically known enum member key without discarding lone @@ -117,13 +118,52 @@ pub(crate) fn static_enum_member_name(property: &MemberProp) -> Option } } +/// Evaluation context for [`EnumValueComputer::compute_rec`]. +/// +/// TypeScript decides enum member constness from the *syntactic* form of the +/// initializer, without type resolution. See +/// https://github.com/microsoft/TypeScript/pull/50528 and the constraints +/// described in https://github.com/evanw/esbuild/issues/4387. +#[derive(Clone, Copy)] +pub(crate) struct EvalCtx { + /// Whether a reference to a `const` binding may be resolved to its value. + /// Disabled under type syntax: `enum E { A = foo as string }` is not a + /// constant for TypeScript even when `foo` is. + allow_const_var: bool, + /// Whether type syntax makes the whole expression non-constant instead of + /// being stripped. Used when evaluating a `const` initializer, where any + /// annotation or assertion removes constness. + ts_is_opaque: bool, +} + +impl EvalCtx { + /// Context for a `const` variable initializer. + pub(crate) const CONST_INIT: Self = Self { + allow_const_var: true, + ts_is_opaque: true, + }; + /// Context for an enum member initializer. + pub(crate) const MEMBER: Self = Self { + allow_const_var: true, + ts_is_opaque: false, + }; + /// Context for re-computing a member whose value the pre-pass already + /// classified as non-constant. Resolving `const` bindings here would + /// override that verdict: the binding map is complete by this point, and + /// type assertions have already been stripped from the initializer. + pub(crate) const RECOMPUTE: Self = Self { + allow_const_var: false, + ts_is_opaque: false, + }; +} + /// https://github.com/microsoft/TypeScript/pull/50528 impl EnumValueComputer<'_> { - pub fn compute(&self, expr: Box) -> TsEnumRecordValue { - self.compute_rec(expr) + pub fn compute(&self, expr: Box, ctx: EvalCtx) -> TsEnumRecordValue { + self.compute_rec(expr, ctx) } - fn compute_rec(&self, expr: Box) -> TsEnumRecordValue { + fn compute_rec(&self, expr: Box, ctx: EvalCtx) -> TsEnumRecordValue { match *expr { Expr::Lit(Lit::Str(s)) => TsEnumRecordValue::String(s.value), Expr::Lit(Lit::Num(n)) => TsEnumRecordValue::Number(n), @@ -155,29 +195,51 @@ impl EnumValueComputer<'_> { } } } - Expr::Paren(e) => self.compute_rec(e.expr), - Expr::Unary(e) => self.compute_unary(e), - Expr::Bin(e) => self.compute_bin(e), - Expr::Member(e) => self.compute_member(e), - Expr::Tpl(e) => self.compute_tpl(e), + // A reference to a `const` binding whose initializer is a constant + // expression. TypeScript treats it as a compile-time constant, so + // the member is emitted as a string enum instead of a reverse + // mapping. Only reachable when not under type syntax. + Expr::Ident(ref ident) if ctx.allow_const_var => self + .const_vars + .get(&ident.to_id()) + .cloned() + .unwrap_or_else(|| TsEnumRecordValue::Opaque(expr)), + Expr::Paren(e) => self.compute_rec(e.expr, ctx), + Expr::Unary(e) => self.compute_unary(e, ctx), + Expr::Bin(e) => self.compute_bin(e, ctx), + Expr::Member(e) => self.compute_member(e, ctx), + Expr::Tpl(e) => self.compute_tpl(e, ctx), // Handle TypeScript type expressions by stripping them // and computing the inner expression - Expr::TsAs(TsAsExpr { expr, .. }) - | Expr::TsNonNull(TsNonNullExpr { expr, .. }) - | Expr::TsTypeAssertion(TsTypeAssertion { expr, .. }) - | Expr::TsConstAssertion(TsConstAssertion { expr, .. }) - | Expr::TsInstantiation(TsInstantiation { expr, .. }) - | Expr::TsSatisfies(TsSatisfiesExpr { expr, .. }) => self.compute_rec(expr), + Expr::TsAs(TsAsExpr { expr: inner, .. }) + | Expr::TsNonNull(TsNonNullExpr { expr: inner, .. }) + | Expr::TsTypeAssertion(TsTypeAssertion { expr: inner, .. }) + | Expr::TsConstAssertion(TsConstAssertion { expr: inner, .. }) + | Expr::TsInstantiation(TsInstantiation { expr: inner, .. }) + | Expr::TsSatisfies(TsSatisfiesExpr { expr: inner, .. }) => { + if ctx.ts_is_opaque { + // Any annotation or assertion removes constness from a + // `const` initializer. + return TsEnumRecordValue::Opaque(inner); + } + self.compute_rec( + inner, + EvalCtx { + allow_const_var: false, + ..ctx + }, + ) + } _ => TsEnumRecordValue::Opaque(expr), } } - fn compute_unary(&self, expr: UnaryExpr) -> TsEnumRecordValue { + fn compute_unary(&self, expr: UnaryExpr, ctx: EvalCtx) -> TsEnumRecordValue { if !matches!(expr.op, op!(unary, "+") | op!(unary, "-") | op!("~")) { return TsEnumRecordValue::Opaque(expr.into()); } - let inner = self.compute_rec(expr.arg); + let inner = self.compute_rec(expr.arg, ctx); let TsEnumRecordValue::Number(num) = inner else { return TsEnumRecordValue::Opaque( @@ -198,7 +260,7 @@ impl EnumValueComputer<'_> { } } - fn compute_bin(&self, expr: BinExpr) -> TsEnumRecordValue { + fn compute_bin(&self, expr: BinExpr, ctx: EvalCtx) -> TsEnumRecordValue { let origin_expr = expr.clone(); if !matches!( expr.op, @@ -218,8 +280,8 @@ impl EnumValueComputer<'_> { return TsEnumRecordValue::Opaque(origin_expr.into()); } - let left = self.compute_rec(expr.left); - let right = self.compute_rec(expr.right); + let left = self.compute_rec(expr.left, ctx); + let right = self.compute_rec(expr.right, ctx); if expr.op == BinaryOp::Add && (left.is_string() || right.is_string()) { let mut value = Wtf8Buf::new(); @@ -267,7 +329,7 @@ impl EnumValueComputer<'_> { } } - fn compute_member(&self, expr: MemberExpr) -> TsEnumRecordValue { + fn compute_member(&self, expr: MemberExpr, _ctx: EvalCtx) -> TsEnumRecordValue { let opaque_expr = TsEnumRecordValue::Opaque(expr.clone().into()); let Some(member_name) = static_enum_member_name(&expr.prop) else { @@ -288,7 +350,7 @@ impl EnumValueComputer<'_> { .unwrap_or(opaque_expr) } - fn compute_tpl(&self, expr: Tpl) -> TsEnumRecordValue { + fn compute_tpl(&self, expr: Tpl, ctx: EvalCtx) -> TsEnumRecordValue { let opaque_expr = TsEnumRecordValue::Opaque(expr.clone().into()); let Tpl { exprs, quasis, .. } = expr; @@ -304,7 +366,7 @@ impl EnumValueComputer<'_> { let mut string = Wtf8Buf::from(first_cooked); for (q, expr) in quasis_iter.zip(exprs) { - let expr = self.compute_rec(expr); + let expr = self.compute_rec(expr, ctx); if !expr.push_to_string(&mut string) { return opaque_expr; diff --git a/crates/swc_ecma_transforms_typescript/tests/fixture/issue-11715-const-var/input.ts b/crates/swc_ecma_transforms_typescript/tests/fixture/issue-11715-const-var/input.ts new file mode 100644 index 000000000000..0c6f68bf376e --- /dev/null +++ b/crates/swc_ecma_transforms_typescript/tests/fixture/issue-11715-const-var/input.ts @@ -0,0 +1,20 @@ +const s = "aThisIs"; +const n = 1; +const concat = "a" + "b"; +const chain = s; + +enum StringEnum { + A = s, + B = "bThisIs", +} + +enum NumericAutoIncrement { + A = n, + B, + C, +} + +enum ConstantExpr { + A = concat, + B = chain, +} diff --git a/crates/swc_ecma_transforms_typescript/tests/fixture/issue-11715-const-var/output.js b/crates/swc_ecma_transforms_typescript/tests/fixture/issue-11715-const-var/output.js new file mode 100644 index 000000000000..29447891a4c9 --- /dev/null +++ b/crates/swc_ecma_transforms_typescript/tests/fixture/issue-11715-const-var/output.js @@ -0,0 +1,20 @@ +const s = "aThisIs"; +const n = 1; +const concat = "a" + "b"; +const chain = s; +var StringEnum = /*#__PURE__*/ function(StringEnum) { + StringEnum["A"] = "aThisIs"; + StringEnum["B"] = "bThisIs"; + return StringEnum; +}(StringEnum || {}); +var NumericAutoIncrement = /*#__PURE__*/ function(NumericAutoIncrement) { + NumericAutoIncrement[NumericAutoIncrement["A"] = 1] = "A"; + NumericAutoIncrement[NumericAutoIncrement["B"] = 2] = "B"; + NumericAutoIncrement[NumericAutoIncrement["C"] = 3] = "C"; + return NumericAutoIncrement; +}(NumericAutoIncrement || {}); +var ConstantExpr = /*#__PURE__*/ function(ConstantExpr) { + ConstantExpr["A"] = "ab"; + ConstantExpr["B"] = "aThisIs"; + return ConstantExpr; +}(ConstantExpr || {}); diff --git a/crates/swc_ecma_transforms_typescript/tests/fixture/issue-11715-not-const/input.ts b/crates/swc_ecma_transforms_typescript/tests/fixture/issue-11715-not-const/input.ts new file mode 100644 index 000000000000..dcfff51304b0 --- /dev/null +++ b/crates/swc_ecma_transforms_typescript/tests/fixture/issue-11715-not-const/input.ts @@ -0,0 +1,35 @@ +const annotated: string = "s"; +const asserted = "s" as string; +const constAsserted = "s" as const; +let mutable = "s"; +var hoisted = "s"; +const { destructured } = { destructured: "s" }; +const poisoned = "s"; + +enum TypeAnnotation { + A = annotated, + B = "b", +} + +enum TypeAssertion { + A = asserted, + B = constAsserted, +} + +enum NotConstBinding { + A = mutable, + B = hoisted, + C = destructured, +} + +enum AssertionOnReference { + A = poisoned as string, + B = "b", +} + +enum ForwardReference { + A = declaredLater, + B = "b", +} + +const declaredLater = "s"; diff --git a/crates/swc_ecma_transforms_typescript/tests/fixture/issue-11715-not-const/output.js b/crates/swc_ecma_transforms_typescript/tests/fixture/issue-11715-not-const/output.js new file mode 100644 index 000000000000..9a7cfa1e0c62 --- /dev/null +++ b/crates/swc_ecma_transforms_typescript/tests/fixture/issue-11715-not-const/output.js @@ -0,0 +1,36 @@ +const annotated = "s"; +const asserted = "s"; +const constAsserted = "s"; +let mutable = "s"; +var hoisted = "s"; +const { destructured } = { + destructured: "s" +}; +const poisoned = "s"; +var TypeAnnotation = function(TypeAnnotation) { + TypeAnnotation[TypeAnnotation["A"] = annotated] = "A"; + TypeAnnotation["B"] = "b"; + return TypeAnnotation; +}(TypeAnnotation || {}); +var TypeAssertion = function(TypeAssertion) { + TypeAssertion[TypeAssertion["A"] = asserted] = "A"; + TypeAssertion[TypeAssertion["B"] = constAsserted] = "B"; + return TypeAssertion; +}(TypeAssertion || {}); +var NotConstBinding = function(NotConstBinding) { + NotConstBinding[NotConstBinding["A"] = mutable] = "A"; + NotConstBinding[NotConstBinding["B"] = hoisted] = "B"; + NotConstBinding[NotConstBinding["C"] = destructured] = "C"; + return NotConstBinding; +}(NotConstBinding || {}); +var AssertionOnReference = function(AssertionOnReference) { + AssertionOnReference[AssertionOnReference["A"] = poisoned] = "A"; + AssertionOnReference["B"] = "b"; + return AssertionOnReference; +}(AssertionOnReference || {}); +var ForwardReference = function(ForwardReference) { + ForwardReference[ForwardReference["A"] = declaredLater] = "A"; + ForwardReference["B"] = "b"; + return ForwardReference; +}(ForwardReference || {}); +const declaredLater = "s"; diff --git a/crates/swc_ecma_transforms_typescript/tests/fixture/issue-11715-scope/input.ts b/crates/swc_ecma_transforms_typescript/tests/fixture/issue-11715-scope/input.ts new file mode 100644 index 000000000000..e52ceab875ca --- /dev/null +++ b/crates/swc_ecma_transforms_typescript/tests/fixture/issue-11715-scope/input.ts @@ -0,0 +1,30 @@ +declare const ambient: string; + +const outer = "outer"; + +enum Ambient { + A = ambient, + B = "b", +} + +function inFunction() { + const inner = "inner"; + enum Local { + A = inner, + B = "b", + } + return Local; +} + +namespace NS { + const scoped = "scoped"; + export enum InNamespace { + A = scoped, + B = "b", + } +} + +enum ShadowedOuter { + A = outer, + B = "b", +} diff --git a/crates/swc_ecma_transforms_typescript/tests/fixture/issue-11715-scope/output.js b/crates/swc_ecma_transforms_typescript/tests/fixture/issue-11715-scope/output.js new file mode 100644 index 000000000000..44646c4884ea --- /dev/null +++ b/crates/swc_ecma_transforms_typescript/tests/fixture/issue-11715-scope/output.js @@ -0,0 +1,28 @@ +const outer = "outer"; +var Ambient = function(Ambient) { + Ambient[Ambient["A"] = ambient] = "A"; + Ambient["B"] = "b"; + return Ambient; +}(Ambient || {}); +function inFunction() { + const inner = "inner"; + let Local = /*#__PURE__*/ function(Local) { + Local["A"] = "inner"; + Local["B"] = "b"; + return Local; + }({}); + return Local; +} +(function(NS) { + const scoped = "scoped"; + (function(InNamespace) { + InNamespace["A"] = "scoped"; + InNamespace["B"] = "b"; + })(NS.InNamespace || (NS.InNamespace = {})); +})(NS || (NS = {})); +var ShadowedOuter = /*#__PURE__*/ function(ShadowedOuter) { + ShadowedOuter["A"] = "outer"; + ShadowedOuter["B"] = "b"; + return ShadowedOuter; +}(ShadowedOuter || {}); +var NS; From c093ba860003767a23182259d0f25d7d96660f53 Mon Sep 17 00:00:00 2001 From: baltasarblanco Date: Wed, 5 Aug 2026 13:18:10 -0300 Subject: [PATCH 2/8] chore: Add changeset --- .changeset/fix-ts-enum-const-var-folding.md | 5 +++++ 1 file changed, 5 insertions(+) create mode 100644 .changeset/fix-ts-enum-const-var-folding.md diff --git a/.changeset/fix-ts-enum-const-var-folding.md b/.changeset/fix-ts-enum-const-var-folding.md new file mode 100644 index 000000000000..230fb8e1432a --- /dev/null +++ b/.changeset/fix-ts-enum-const-var-folding.md @@ -0,0 +1,5 @@ +--- +swc_core: patch +swc_ecma_transforms_typescript: patch +--- +fix(es/typescript): Treat const variable references as enum constants From 0851e3e4fcb19a80a6b86c7b54e8128b73080fc6 Mon Sep 17 00:00:00 2001 From: baltasarblanco Date: Wed, 5 Aug 2026 13:44:42 -0300 Subject: [PATCH 3/8] test(es/typescript): Update constEnum2 tsc reference `g = CONST` now resolves to a constant, so the member is inlined and no longer emitted, matching how `d = 10` is already handled in the same `const enum`. The remaining members stay opaque because they call `Math.random()`. --- crates/swc/tests/tsc-references/constEnum2.1.normal.js | 1 - crates/swc/tests/tsc-references/constEnum2.2.minified.js | 2 +- 2 files changed, 1 insertion(+), 2 deletions(-) diff --git a/crates/swc/tests/tsc-references/constEnum2.1.normal.js b/crates/swc/tests/tsc-references/constEnum2.1.normal.js index 3ebd779cec05..5e298eb832dc 100644 --- a/crates/swc/tests/tsc-references/constEnum2.1.normal.js +++ b/crates/swc/tests/tsc-references/constEnum2.1.normal.js @@ -7,6 +7,5 @@ var CONST = 9000 % 2; var D = function(D) { D[D["e"] = 199 * Math.floor(Math.random() * 1000)] = "e"; D[D["f"] = 10 - 100 * Math.floor(Math.random() % 8)] = "f"; - D[D["g"] = CONST] = "g"; return D; }(D || {}); diff --git a/crates/swc/tests/tsc-references/constEnum2.2.minified.js b/crates/swc/tests/tsc-references/constEnum2.2.minified.js index 01cbd3326368..64cf8153a14d 100644 --- a/crates/swc/tests/tsc-references/constEnum2.2.minified.js +++ b/crates/swc/tests/tsc-references/constEnum2.2.minified.js @@ -1,2 +1,2 @@ //// [constEnum2.ts] -var D, D1 = ((D = D1 || {})[D.e = 199 * Math.floor(1000 * Math.random())] = "e", D[D.f = 10 - 100 * Math.floor(Math.random() % 8)] = "f", D[D.g = 0] = "g", D); +var D, D1 = ((D = D1 || {})[D.e = 199 * Math.floor(1000 * Math.random())] = "e", D[D.f = 10 - 100 * Math.floor(Math.random() % 8)] = "f", D); From d547a9a63cff2c8e5c8d2800c15dc509acbfe9b8 Mon Sep 17 00:00:00 2001 From: baltasarblanco Date: Wed, 5 Aug 2026 14:59:40 -0300 Subject: [PATCH 4/8] fix(es/typescript): Resolve const initializers against seen enums and prior declarators The const collection pass evaluated each initializer against an empty `TsEnumRecord`, so `const x = E.A` could not resolve an already-recorded enum member and was dropped from `const_vars`. It also visited every declarator of a `VarDecl` before recording any of them, so a nested enum in a later initializer could not see an earlier `const` from the same declaration. Both left the member opaque, and `inc()` returns `Void` for an opaque value, so the following auto-incremented members collapsed to `undefined`. Evaluate against the accumulated enum record, and record each declarator before traversing the next one. Order across separate statements is unchanged: a `const` declared after the enum is still invisible to it. --- .../src/semantic.rs | 15 ++++++++------- 1 file changed, 8 insertions(+), 7 deletions(-) diff --git a/crates/swc_ecma_transforms_typescript/src/semantic.rs b/crates/swc_ecma_transforms_typescript/src/semantic.rs index 176b6ff71e7d..c332fc44ca38 100644 --- a/crates/swc_ecma_transforms_typescript/src/semantic.rs +++ b/crates/swc_ecma_transforms_typescript/src/semantic.rs @@ -540,23 +540,24 @@ impl Visit for SemanticAnalyzer { } fn visit_var_decl(&mut self, node: &VarDecl) { - node.visit_children_with(self); - - if self.skip_transform_info || node.declare || node.kind != VarDeclKind::Const { - return; - } + let track = !self.skip_transform_info && !node.declare && node.kind == VarDeclKind::Const; for decl in &node.decls { + decl.visit_with(self); + + if !track { + continue; + } + let Pat::Ident(BindingIdent { id, type_ann: None }) = &decl.name else { continue; }; let Some(init) = &decl.init else { continue }; - let empty = TsEnumRecord::default(); let value = EnumValueComputer { enum_id: &id.to_id(), unresolved_ctxt: self.unresolved_ctxt, - record: &empty, + record: &self.info.enum_record, const_vars: &self.info.const_vars, } .compute(init.clone(), EvalCtx::CONST_INIT); From dd1035ad8f98d56f04466226ce68773154861296 Mon Sep 17 00:00:00 2001 From: baltasarblanco Date: Wed, 5 Aug 2026 16:07:02 -0300 Subject: [PATCH 5/8] test(es/typescript): Cover enum-member and multi-declarator const folding Two cases raised in review, both of which fail on main: - `enum E { A = "a" } const x = E.A; enum F { X = x }`, where the const initializer references an already-declared enum member. - `const x = 1, y = (() => { enum E { A = x, B } })()`, where a nested enum references an earlier declarator of the same declaration. Both were left opaque, so the following auto-incremented member collapsed to `undefined`. --- .../issue-11715-declarator-order/input.ts | 7 +++++++ .../issue-11715-declarator-order/output.js | 8 ++++++++ .../issue-11715-enum-member-init/input.ts | 17 ++++++++++++++++ .../issue-11715-enum-member-init/output.js | 20 +++++++++++++++++++ 4 files changed, 52 insertions(+) create mode 100644 crates/swc_ecma_transforms_typescript/tests/fixture/issue-11715-declarator-order/input.ts create mode 100644 crates/swc_ecma_transforms_typescript/tests/fixture/issue-11715-declarator-order/output.js create mode 100644 crates/swc_ecma_transforms_typescript/tests/fixture/issue-11715-enum-member-init/input.ts create mode 100644 crates/swc_ecma_transforms_typescript/tests/fixture/issue-11715-enum-member-init/output.js diff --git a/crates/swc_ecma_transforms_typescript/tests/fixture/issue-11715-declarator-order/input.ts b/crates/swc_ecma_transforms_typescript/tests/fixture/issue-11715-declarator-order/input.ts new file mode 100644 index 000000000000..956065a3f69c --- /dev/null +++ b/crates/swc_ecma_transforms_typescript/tests/fixture/issue-11715-declarator-order/input.ts @@ -0,0 +1,7 @@ +const first = 1, second = (()=>{ + enum Nested { + A = first, + B + } + return Nested; +})(); diff --git a/crates/swc_ecma_transforms_typescript/tests/fixture/issue-11715-declarator-order/output.js b/crates/swc_ecma_transforms_typescript/tests/fixture/issue-11715-declarator-order/output.js new file mode 100644 index 000000000000..383e6aa66f8a --- /dev/null +++ b/crates/swc_ecma_transforms_typescript/tests/fixture/issue-11715-declarator-order/output.js @@ -0,0 +1,8 @@ +const first = 1, second = (()=>{ + let Nested = /*#__PURE__*/ function(Nested) { + Nested[Nested["A"] = 1] = "A"; + Nested[Nested["B"] = 2] = "B"; + return Nested; + }({}); + return Nested; +})(); diff --git a/crates/swc_ecma_transforms_typescript/tests/fixture/issue-11715-enum-member-init/input.ts b/crates/swc_ecma_transforms_typescript/tests/fixture/issue-11715-enum-member-init/input.ts new file mode 100644 index 000000000000..2b3b26fb3c8d --- /dev/null +++ b/crates/swc_ecma_transforms_typescript/tests/fixture/issue-11715-enum-member-init/input.ts @@ -0,0 +1,17 @@ +enum Str { + A = "a" +} +const fromStr = Str.A; +enum FromEnumMemberStr { + X = fromStr, + Y = "y" +} + +enum Num { + A = 1 +} +const fromNum = Num.A; +enum FromEnumMemberNum { + X = fromNum, + Y +} diff --git a/crates/swc_ecma_transforms_typescript/tests/fixture/issue-11715-enum-member-init/output.js b/crates/swc_ecma_transforms_typescript/tests/fixture/issue-11715-enum-member-init/output.js new file mode 100644 index 000000000000..0c7b6a16fdd2 --- /dev/null +++ b/crates/swc_ecma_transforms_typescript/tests/fixture/issue-11715-enum-member-init/output.js @@ -0,0 +1,20 @@ +var Str = /*#__PURE__*/ function(Str) { + Str["A"] = "a"; + return Str; +}(Str || {}); +const fromStr = "a"; +var FromEnumMemberStr = /*#__PURE__*/ function(FromEnumMemberStr) { + FromEnumMemberStr["X"] = "a"; + FromEnumMemberStr["Y"] = "y"; + return FromEnumMemberStr; +}(FromEnumMemberStr || {}); +var Num = /*#__PURE__*/ function(Num) { + Num[Num["A"] = 1] = "A"; + return Num; +}(Num || {}); +const fromNum = 1; +var FromEnumMemberNum = /*#__PURE__*/ function(FromEnumMemberNum) { + FromEnumMemberNum[FromEnumMemberNum["X"] = 1] = "X"; + FromEnumMemberNum[FromEnumMemberNum["Y"] = 2] = "Y"; + return FromEnumMemberNum; +}(FromEnumMemberNum || {}); From f6f9b2773921b7dd9c905e1cbe38c42fe16d99b1 Mon Sep 17 00:00:00 2001 From: baltasarblanco Date: Wed, 5 Aug 2026 16:07:02 -0300 Subject: [PATCH 6/8] perf(es/typescript): Skip const initializers that cannot be constant `compute` takes the initializer by value, so the collection pass cloned every `const` initializer in the program, while most of them hit the `Opaque` catch-all right away: in real TypeScript they are mostly arrow functions, object literals and calls. Check the outermost form before cloning. `es/transform/baseline/common_typescript` goes from 59.6 us back to 53.3 us, against 53.3 us on main. No test output changes. --- .../src/semantic.rs | 4 +++ .../src/ts_enum.rs | 30 +++++++++++++++++++ 2 files changed, 34 insertions(+) diff --git a/crates/swc_ecma_transforms_typescript/src/semantic.rs b/crates/swc_ecma_transforms_typescript/src/semantic.rs index c332fc44ca38..631e1e891737 100644 --- a/crates/swc_ecma_transforms_typescript/src/semantic.rs +++ b/crates/swc_ecma_transforms_typescript/src/semantic.rs @@ -554,6 +554,10 @@ impl Visit for SemanticAnalyzer { }; let Some(init) = &decl.init else { continue }; + if !EnumValueComputer::can_fold_shape(init) { + continue; + } + let value = EnumValueComputer { enum_id: &id.to_id(), unresolved_ctxt: self.unresolved_ctxt, diff --git a/crates/swc_ecma_transforms_typescript/src/ts_enum.rs b/crates/swc_ecma_transforms_typescript/src/ts_enum.rs index 2aee76b6f227..dfd12f40877a 100644 --- a/crates/swc_ecma_transforms_typescript/src/ts_enum.rs +++ b/crates/swc_ecma_transforms_typescript/src/ts_enum.rs @@ -159,6 +159,36 @@ impl EvalCtx { /// https://github.com/microsoft/TypeScript/pull/50528 impl EnumValueComputer<'_> { + /// Whether [`Self::compute`] could fold this expression at all, decided + /// from its outermost form alone. + /// + /// `compute` takes the expression by value, so evaluating an initializer + /// means cloning it. Every form not listed here hits the `Opaque` + /// catch-all immediately, and `const` initializers in real TypeScript are + /// mostly arrow functions, object literals and calls — cloning those to + /// discard the result dominated the cost of the collection pass. + /// + /// Kept in sync with the arms of `compute_rec`. An omission here can only + /// under-fold; it can never produce a wrong value. + pub fn can_fold_shape(expr: &Expr) -> bool { + matches!( + expr, + Expr::Lit(..) + | Expr::Ident(..) + | Expr::Paren(..) + | Expr::Unary(..) + | Expr::Bin(..) + | Expr::Member(..) + | Expr::Tpl(..) + | Expr::TsAs(..) + | Expr::TsNonNull(..) + | Expr::TsTypeAssertion(..) + | Expr::TsConstAssertion(..) + | Expr::TsInstantiation(..) + | Expr::TsSatisfies(..) + ) + } + pub fn compute(&self, expr: Box, ctx: EvalCtx) -> TsEnumRecordValue { self.compute_rec(expr, ctx) } From 8eeebc5a51d1a66196ca22f9cbb897921452412a Mon Sep 17 00:00:00 2001 From: baltasarblanco Date: Thu, 6 Aug 2026 00:12:16 -0300 Subject: [PATCH 7/8] fix(es/typescript): Keep mutable enum reads opaque in const initializers MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Resolving const initializers against the enum record made `const x = D.A; enum E { A = x }` fold to the compile-time value even with `tsEnumIsMutable` enabled, where `enter_expr_for_inline_enum` deliberately leaves reads of non-const enums as runtime reads. The emitted code contradicted itself: `const x = D.A` was preserved while `E.A` used the folded value. Thread the option into the collection pass and mirror the guard from `transform.rs`: when mutable enums are enabled, only members of `const enum` declarations resolve through a member expression. Members of the enum being evaluated are unaffected, and the default path (`ts_enum_is_mutable: false`) is unchanged — no test output moves. --- .../src/semantic.rs | 5 +++++ .../src/transform.rs | 1 + .../src/ts_enum.rs | 20 +++++++++++++++++-- .../src/typescript.rs | 1 + 4 files changed, 25 insertions(+), 2 deletions(-) diff --git a/crates/swc_ecma_transforms_typescript/src/semantic.rs b/crates/swc_ecma_transforms_typescript/src/semantic.rs index 631e1e891737..7dde0b3ce8fb 100644 --- a/crates/swc_ecma_transforms_typescript/src/semantic.rs +++ b/crates/swc_ecma_transforms_typescript/src/semantic.rs @@ -49,6 +49,7 @@ pub(crate) fn analyze_program( unresolved_mark: Mark, seed_usage: FxHashSet, flow_syntax: bool, + ts_enum_is_mutable: bool, ) -> SemanticInfo { let mut analyzer = SemanticAnalyzer { unresolved_ctxt: SyntaxContext::empty().apply_mark(unresolved_mark), @@ -61,6 +62,7 @@ pub(crate) fn analyze_program( namespace_id: None, skip_transform_info: false, flow_syntax, + ts_enum_is_mutable, }; program.visit_with(&mut analyzer); @@ -76,6 +78,7 @@ struct SemanticAnalyzer { namespace_id: Option, skip_transform_info: bool, flow_syntax: bool, + ts_enum_is_mutable: bool, } #[derive(Default)] @@ -270,6 +273,7 @@ impl SemanticAnalyzer { unresolved_ctxt, record, const_vars, + const_enum_only: None, } .compute(expr, EvalCtx::MEMBER) }) @@ -563,6 +567,7 @@ impl Visit for SemanticAnalyzer { unresolved_ctxt: self.unresolved_ctxt, record: &self.info.enum_record, const_vars: &self.info.const_vars, + const_enum_only: self.ts_enum_is_mutable.then_some(&self.info.const_enum), } .compute(init.clone(), EvalCtx::CONST_INIT); diff --git a/crates/swc_ecma_transforms_typescript/src/transform.rs b/crates/swc_ecma_transforms_typescript/src/transform.rs index 4edc59fe9b78..6300ce4ba84a 100644 --- a/crates/swc_ecma_transforms_typescript/src/transform.rs +++ b/crates/swc_ecma_transforms_typescript/src/transform.rs @@ -1115,6 +1115,7 @@ impl Transform { unresolved_ctxt: self.unresolved_ctxt, record: &self.semantic.enum_record, const_vars: &self.semantic.const_vars, + const_enum_only: None, }; let member_list: Vec<_> = members diff --git a/crates/swc_ecma_transforms_typescript/src/ts_enum.rs b/crates/swc_ecma_transforms_typescript/src/ts_enum.rs index dfd12f40877a..a4b3165407ec 100644 --- a/crates/swc_ecma_transforms_typescript/src/ts_enum.rs +++ b/crates/swc_ecma_transforms_typescript/src/ts_enum.rs @@ -1,4 +1,4 @@ -use rustc_hash::FxHashMap; +use rustc_hash::{FxHashMap, FxHashSet}; use swc_atoms::{wtf8::Wtf8Buf, Atom, Wtf8Atom}; use swc_common::{SyntaxContext, DUMMY_SP}; use swc_ecma_ast::*; @@ -98,6 +98,13 @@ pub(crate) struct EnumValueComputer<'a> { pub unresolved_ctxt: SyntaxContext, pub record: &'a TsEnumRecord, pub const_vars: &'a FxHashMap, + /// When `Some`, only enums in this set may be resolved through a member + /// expression. + /// + /// Mirrors the `ts_enum_is_mutable` guard in `transform.rs`: a non-const + /// enum can be reassigned at runtime, so reads of its members are not + /// compile-time constants and must stay opaque. + pub const_enum_only: Option<&'a FxHashSet>, } /// Returns a statically known enum member key without discarding lone @@ -370,9 +377,18 @@ impl EnumValueComputer<'_> { return opaque_expr; }; + let enum_id = ident.to_id(); + + if self + .const_enum_only + .is_some_and(|set| !set.contains(&enum_id)) + { + return opaque_expr; + } + self.record .get(&TsEnumRecordKey { - enum_id: ident.to_id(), + enum_id, member_name, }) .cloned() diff --git a/crates/swc_ecma_transforms_typescript/src/typescript.rs b/crates/swc_ecma_transforms_typescript/src/typescript.rs index 6a79fecf30cc..22cdc98688f3 100644 --- a/crates/swc_ecma_transforms_typescript/src/typescript.rs +++ b/crates/swc_ecma_transforms_typescript/src/typescript.rs @@ -52,6 +52,7 @@ impl Pass for TypeScript { self.unresolved_mark, mem::take(&mut self.id_usage), self.config.flow_syntax, + self.config.ts_enum_is_mutable, ); n.mutate(transform( From 2e8749f8a748c8277fa9efd4eabe402a931792c0 Mon Sep 17 00:00:00 2001 From: baltasarblanco Date: Thu, 6 Aug 2026 00:15:42 -0300 Subject: [PATCH 8/8] test(es/typescript): Cover const initializers under mutable enums MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Extends the existing `ts_enum_is_mutable_true` case with both sides of the guard: a const initialized from a mutable enum member, which must stay a runtime read, and one from a `const enum` member, which still folds. Both fail on main — the first because the value was folded away, the second because it was not folded at all. --- .../tests/strip.rs/ts_enum_is_mutable_true.js | 12 ++++++++++++ .../swc_ecma_transforms_typescript/tests/strip.rs | 13 +++++++++++++ 2 files changed, 25 insertions(+) diff --git a/crates/swc_ecma_transforms_typescript/tests/__swc_snapshots__/tests/strip.rs/ts_enum_is_mutable_true.js b/crates/swc_ecma_transforms_typescript/tests/__swc_snapshots__/tests/strip.rs/ts_enum_is_mutable_true.js index 0ba6cd848efd..3311246ae958 100644 --- a/crates/swc_ecma_transforms_typescript/tests/__swc_snapshots__/tests/strip.rs/ts_enum_is_mutable_true.js +++ b/crates/swc_ecma_transforms_typescript/tests/__swc_snapshots__/tests/strip.rs/ts_enum_is_mutable_true.js @@ -16,3 +16,15 @@ var H = /*#__PURE__*/ function(H) { return H; }(H || {}); console.log(2); +const j = H.A; +var J = function(J) { + J[J["A"] = j] = "A"; + return J; +}(J || {}); +console.log(J.A); +const l = 3; +var L = /*#__PURE__*/ function(L) { + L[L["A"] = 3] = "A"; + return L; +}(L || {}); +console.log(L.A); diff --git a/crates/swc_ecma_transforms_typescript/tests/strip.rs b/crates/swc_ecma_transforms_typescript/tests/strip.rs index 1f3eab3ef3e4..b70189c448ec 100644 --- a/crates/swc_ecma_transforms_typescript/tests/strip.rs +++ b/crates/swc_ecma_transforms_typescript/tests/strip.rs @@ -2843,6 +2843,19 @@ test!( A = H.A } console.log(I.A); + const j = H.A; + enum J { + A = j + } + console.log(J.A); + const enum K { + A = 3, + } + const l = K.A; + enum L { + A = l + } + console.log(L.A); "# );