Skip to content

fix(es/minifier): avoid moved binding collisions#11981

Draft
kdy1 wants to merge 1 commit into
mainfrom
kdy1/fix-11977-minifier-binding
Draft

fix(es/minifier): avoid moved binding collisions#11981
kdy1 wants to merge 1 commit into
mainfrom
kdy1/fix-11977-minifier-binding

Conversation

@kdy1

@kdy1 kdy1 commented Jul 3, 2026

Copy link
Copy Markdown
Member

Problem

IIFE parameter extraction can move bindings from an inner scope into an outer scope that contains direct eval. The rename analysis preserved every declaration whose context descended from the resolver top-level mark when eval was present, so moved/inlined bindings could keep a printed name that collided with an existing same-scope user declaration after compress + mangle.

Fixes #11977.

Changes

  • Track the concrete declaration scope context in swc_ecma_transforms_base::rename analysis.
  • Preserve only real same-scope eval-visible user declarations by context.
  • Keep moved/inner-scope declarations renameable, while reserving preserved same-scope symbols so generated names avoid collisions.
  • Add exec regressions for issue 11977, including the wrapped g.foo = function() { ... } case.

Testing

  • git submodule update --init --recursive
  • cargo test -p swc_ecma_minifier --test exec issue_11977
  • cargo test -p swc_ecma_minifier --test exec issue_11294
  • cargo test -p swc_ecma_minifier
  • cargo fmt --all
  • cargo clippy --all --all-targets -- -D warnings

@kdy1 kdy1 requested a review from a team as a code owner July 3, 2026 04:03
@changeset-bot

changeset-bot Bot commented Jul 3, 2026

Copy link
Copy Markdown

⚠️ No Changeset found

Latest commit: 3aa4da8

Merging this PR will not cause a version bump for any packages. If these changes should not result in a new version, you're good to go. If these changes should result in a version bump, you need to add a changeset.

Click here to learn what changesets are, and how to add one.

Click here if you're a maintainer who wants to add a changeset to this PR

@kdy1 kdy1 requested a review from Austaras July 3, 2026 04:09

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 3aa4da8e55

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".


fn visit_class_expr(&mut self, node: &ClassExpr) {
let old_analyzer = self.analyzer.enter_block_scope();
let old_analyzer = self.analyzer.enter_block_scope(Some(node.class.ctxt));

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Preserve named class expressions seen by eval

With direct eval inside a named class expression, e.g. const C = class Foo { static m() { return eval("Foo") } }, this uses Class::ctxt as the source-scope context, but the resolver leaves Class::ctxt empty and marks the class-expression name in the child scope in visit_mut_class_expr. That makes id.1 == ctxt false for Foo, so the name is queued for mangling even though eval still resolves the string "Foo", regressing the eval-preservation behavior this code is trying to maintain.

Useful? React with 👍 / 👎.


fn visit_constructor(&mut self, node: &Constructor) {
let old_analyzer = self.analyzer.enter_fn_scope();
let old_analyzer = self.analyzer.enter_fn_scope(node.ctxt);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Use a marked constructor context for eval preservation

For class constructors with direct eval, such as class C { constructor(longName) { eval("longName") } }, Constructor::ctxt is never populated by the resolver; visit_mut_constructor creates a child function scope, marks parameters with that child mark, and only marks body.ctxt. Passing the empty node.ctxt here means constructor parameters and locals no longer match scope_ctxt, so they can be mangled while eval still looks up their original names.

Useful? React with 👍 / 👎.

self.decl_collector.is_pat_decl = true;

let old_analyzer = self.analyzer.enter_fn_scope();
let old_analyzer = self.analyzer.enter_fn_scope(node.ctxt);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Preserve arrow parameters visible to eval

For arrows with direct eval, e.g. const f = (longName) => eval("longName"), the resolver creates a child function scope for the parameters but never marks ArrowExpr::ctxt; only a block body gets marked, and expression-bodied arrows have no marked body to use. Passing this empty context makes eval preservation compare the parameter context against SyntaxContext::empty(), so mangle can rename longName while eval still looks up the original string.

Useful? React with 👍 / 👎.

Comment on lines +354 to +355
for id in &self.data.preserved {
reverse.push_entry(id.0.clone(), id.clone());

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Reserve child preserved names before parent renaming

This only inserts eval-preserved names from the current scope into the reverse map; preserved names in child scopes are not reserved until after the parent queue has already been renamed. When an ancestor has a queued moved binding and an eval-containing child preserves a short local such as a, the ancestor can be mangled to a, and references to the ancestor from inside that child will bind to the child's local once contexts are erased.

Useful? React with 👍 / 👎.

@codspeed-hq

codspeed-hq Bot commented Jul 3, 2026

Copy link
Copy Markdown

Merging this PR will degrade performance by 2.35%

⚠️ Different runtime environments detected

Some benchmarks with significant performance changes were compared across different runtime environments,
which may affect the accuracy of the results.

Open the report in CodSpeed to investigate

❌ 1 regressed benchmark
✅ 188 untouched benchmarks
⏩ 61 skipped benchmarks1

Warning

Please fix the performance issues or acknowledge them on CodSpeed.

Performance Changes

Mode Benchmark BASE HEAD Efficiency
Simulation es/hygiene/typescript 431.9 ms 442.3 ms -2.35%

Tip

Investigate this regression by commenting @codspeedbot fix this regression on this PR, or directly use the CodSpeed MCP with your agent.


Comparing kdy1/fix-11977-minifier-binding (3aa4da8) with main (26d8a28)2

Open in CodSpeed

Footnotes

  1. 61 benchmarks were skipped, so the baseline results were used instead. If they were deleted from the codebase, click here and archive them to remove them from the performance reports.

  2. No successful run was found on main (9c8e6f3) during the generation of this report, so 26d8a28 was used instead as the comparison base. There might be some changes unrelated to this pull request in this report.

@Austaras

Austaras commented Jul 4, 2026

Copy link
Copy Markdown
Member

I believe this PR still has its own merit. Maybe in the future we could enable inline/unused pass even in presence of eval

@kdy1

kdy1 commented Jul 4, 2026

Copy link
Copy Markdown
Member Author

I'll keep this as a draft 👍

@kdy1 kdy1 marked this pull request as draft July 4, 2026 12:11
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Development

Successfully merging this pull request may close these issues.

Minifier emits duplicate top-level let x / var x ("Identifier 'x' has already been declared") with compress + mangle

2 participants