Skip to content

fix(semantic): hoist Annex B block-scoped function declarations to var scope#20728

Merged
graphite-app[bot] merged 1 commit intomainfrom
fix/semantic-annex-b-block-scoped-function
Mar 26, 2026
Merged

fix(semantic): hoist Annex B block-scoped function declarations to var scope#20728
graphite-app[bot] merged 1 commit intomainfrom
fix/semantic-annex-b-block-scoped-function

Conversation

@Dunqing
Copy link
Copy Markdown
Member

@Dunqing Dunqing commented Mar 25, 2026

close: #20705

This PR replaces the mangler-level fix in #20705 with a semantic-level fix that is simpler and allows better name reuse.

Summary

In sloppy mode JavaScript, function declarations inside blocks (if, try, switch, {}) have Annex B.3.2.1 semantics: they create an implicit var-like binding in the enclosing function scope at block exit. The semantic analysis was not modeling this hoisting, which caused the mangler to assign conflicting names to block-scoped functions and outer var bindings — changing program behavior at runtime.

How the fix works

Hoist plain (non-async, non-generator) function declarations from block scopes to the enclosing var scope during binding, using the same pattern as var hoisting (binder.rs lines 46-98):

  1. move_binding(block_scope, var_scope, name) — moves the binding
  2. set_symbol_scope_id(symbol_id, var_scope) — updates the symbol's scope
  3. hoisting_variables.insert(block_scope, name, symbol_id) — keeps a hoisting entry in the block scope for redeclaration checks

This makes the mangler handle Annex B functions naturally alongside other var-scope bindings, with zero mangler changes needed. Name reuse from sibling scopes is preserved (unlike the mangler-level fix which was overly conservative).

Guards

  • is_declaration — function expressions are bound in their own (var) scope
  • !self.r#async && !self.generator — Annex B only applies to plain functions
  • !builder.source_type.is_typescript() — Annex B is JavaScript-only
  • !scope_flags.is_var() — already in a var scope, no hoisting needed
  • !scope_flags.is_strict_mode() — no Annex B in strict mode / modules

Fixes #20610. Fixes #14316.

Downstream: vitejs/vite#22009, rolldown/rolldown#8791.

Test plan

  • 13 sloppy-mode mangler snapshot test cases (all pass without mangler changes)
  • 975 linter tests pass
  • 40 semantic unit tests pass
  • Parser conformance: improved (babel negative 1687→1689, test262 negative stays 100%)
  • Semantic conformance: ~10 new mismatches in semantic_test262 — these are transformer rebuild differences where the transformed JS output correctly gets Annex B hoisting that the original TS parse didn't have

Copy link
Copy Markdown
Member Author

Dunqing commented Mar 25, 2026


How to use the Graphite Merge Queue

Add either label to this PR to merge it via the merge queue:

  • 0-merge - adds this PR to the back of the merge queue
  • hotfix - for urgent changes, fast-track this PR to the front of the merge queue

You must have a Graphite account in order to use the merge queue. Sign up using this link.

An organization admin has enabled the Graphite Merge Queue in this repository.

Please do not merge from GitHub as this will restart CI on PRs being processed by the merge queue.

This stack of pull requests is managed by Graphite. Learn more about stacking.

@codspeed-hq
Copy link
Copy Markdown

codspeed-hq bot commented Mar 25, 2026

Merging this PR will not alter performance

✅ 44 untouched benchmarks
⏩ 12 skipped benchmarks1


Comparing fix/semantic-annex-b-block-scoped-function (412a86a) with main (0da7c3b)

Open in CodSpeed

Footnotes

  1. 12 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.

@Dunqing Dunqing force-pushed the fix/semantic-annex-b-block-scoped-function branch 2 times, most recently from 8ba94c9 to 7da639e Compare March 25, 2026 09:12
@Dunqing Dunqing marked this pull request as ready for review March 25, 2026 10:08
@Dunqing Dunqing requested a review from sapphi-red March 25, 2026 10:08
@Dunqing
Copy link
Copy Markdown
Member Author

Dunqing commented Mar 25, 2026

@sapphi-red, could you take a look at the handling in Semantic, which looks clean and follows the spec.

Copy link
Copy Markdown
Member

@sapphi-red sapphi-red left a comment

Choose a reason for hiding this comment

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

Implementation looks good to me.
I think we should add a test that ensures

console.log(`typeof foo is ${typeof foo}`);
if (true) {
  function foo() {
    return 1;
  }
}

is minified to

console.log(typeof foo); // should not be replaced with 'function' nor 'undefined'
if (true) {
  function foo() {
    return 1;
  }
}

just in case.

@Dunqing Dunqing changed the base branch from fix/mangler-annex-b-block-scoped-function to graphite-base/20728 March 26, 2026 01:10
@Dunqing Dunqing force-pushed the fix/semantic-annex-b-block-scoped-function branch from 7da639e to b4c07df Compare March 26, 2026 01:10
@Dunqing Dunqing force-pushed the graphite-base/20728 branch from 14c83d6 to 23050fa Compare March 26, 2026 01:10
@Dunqing Dunqing changed the base branch from graphite-base/20728 to main March 26, 2026 01:10
@github-actions github-actions bot added the A-minifier Area - Minifier label Mar 26, 2026
@Dunqing
Copy link
Copy Markdown
Member Author

Dunqing commented Mar 26, 2026

Implementation looks good to me. I think we should add a test that ensures

console.log(`typeof foo is ${typeof foo}`);
if (true) {
  function foo() {
    return 1;
  }
}

is minified to

console.log(typeof foo); // should not be replaced with 'function' nor 'undefined'
if (true) {
  function foo() {
    return 1;
  }
}

just in case.

Added

@Dunqing
Copy link
Copy Markdown
Member Author

Dunqing commented Mar 26, 2026

I will take a look at those failed semantic tests later.

@Dunqing Dunqing added the 0-merge Merge with Graphite Merge Queue label Mar 26, 2026
Copy link
Copy Markdown
Member Author

Dunqing commented Mar 26, 2026

Merge activity

…r scope (#20728)

This PR replaces the mangler-level fix in #20705 with a semantic-level fix that is simpler and allows better name reuse.

## Summary

In sloppy mode JavaScript, function declarations inside blocks (`if`, `try`, `switch`, `{}`) have **Annex B.3.2.1** semantics: they create an implicit `var`-like binding in the enclosing function scope at block exit. The semantic analysis was not modeling this hoisting, which caused the mangler to assign conflicting names to block-scoped functions and outer `var` bindings — changing program behavior at runtime.

### How the fix works

Hoist plain (non-async, non-generator) function declarations from block scopes to the enclosing var scope during binding, using the **same pattern as `var` hoisting** (binder.rs lines 46-98):

1. `move_binding(block_scope, var_scope, name)` — moves the binding
2. `set_symbol_scope_id(symbol_id, var_scope)` — updates the symbol's scope
3. `hoisting_variables.insert(block_scope, name, symbol_id)` — keeps a hoisting entry in the block scope for redeclaration checks

This makes the mangler handle Annex B functions naturally alongside other var-scope bindings, with **zero mangler changes needed**. Name reuse from sibling scopes is preserved (unlike the mangler-level fix which was overly conservative).

### Guards

- `is_declaration` — function expressions are bound in their own (var) scope
- `!self.r#async && !self.generator` — Annex B only applies to plain functions
- `!builder.source_type.is_typescript()` — Annex B is JavaScript-only
- `!scope_flags.is_var()` — already in a var scope, no hoisting needed
- `!scope_flags.is_strict_mode()` — no Annex B in strict mode / modules

Fixes #20610. Fixes #14316.

Downstream: [vitejs/vite#22009](vitejs/vite#22009), [rolldown/rolldown#8791](rolldown/rolldown#8791).

## Test plan

- 13 sloppy-mode mangler snapshot test cases (all pass without mangler changes)
- 975 linter tests pass
- 40 semantic unit tests pass
- Parser conformance: improved (babel negative 1687→1689, test262 negative stays 100%)
- Semantic conformance: ~10 new mismatches in `semantic_test262` — these are transformer rebuild differences where the transformed JS output correctly gets Annex B hoisting that the original TS parse didn't have
@graphite-app graphite-app bot force-pushed the fix/semantic-annex-b-block-scoped-function branch from 412a86a to 9a5ff73 Compare March 26, 2026 01:21
@graphite-app graphite-app bot merged commit 9a5ff73 into main Mar 26, 2026
27 checks passed
@graphite-app graphite-app bot removed the 0-merge Merge with Graphite Merge Queue label Mar 26, 2026
@graphite-app graphite-app bot deleted the fix/semantic-annex-b-block-scoped-function branch March 26, 2026 01:25
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

A-minifier Area - Minifier A-semantic Area - Semantic C-bug Category - Bug

Projects

None yet

Development

Successfully merging this pull request may close these issues.

mangler: function not given a unique slot in sloppy mode minifier: mangling with function declared in try block in non-strict mode causes conflict

2 participants