fix(mangler): prevent slot reuse for block-scoped functions in sloppy mode#20705
fix(mangler): prevent slot reuse for block-scoped functions in sloppy mode#20705
Conversation
How to use the Graphite Merge QueueAdd either label to this PR to merge it via 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. |
8f2f0d7 to
f548a0e
Compare
Merging this PR will degrade performance by 5.53%
|
| Mode | Benchmark | BASE |
HEAD |
Efficiency | |
|---|---|---|---|---|---|
| ❌ | Simulation | mangler[RadixUIAdoptionSection.jsx_keep_names] |
20.2 µs | 20.9 µs | -3.31% |
| ❌ | Simulation | mangler[cal.com.tsx] |
2.7 ms | 2.8 ms | -4.6% |
| ❌ | Simulation | mangler[RadixUIAdoptionSection.jsx] |
14.4 µs | 15.2 µs | -5.38% |
| ❌ | Simulation | mangler[react.development.js] |
225.9 µs | 239.1 µs | -5.53% |
| ❌ | Simulation | mangler[binder.ts] |
644.4 µs | 672.7 µs | -4.2% |
Comparing fix/mangler-annex-b-block-scoped-function (14c83d6) with main (b02fe6e)2
Footnotes
-
7 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. ↩
-
No successful run was found on
main(2938e8a) during the generation of this report, so b02fe6e was used instead as the comparison base. There might be some changes unrelated to this pull request in this report. ↩
… mode
In sloppy mode JavaScript, function declarations inside blocks (if, try,
switch, {}) have Annex B.3.2.1 semantics: they create an implicit var-like
assignment in the enclosing function scope at block exit. The mangler was
treating these as purely block-scoped (like let/const), allowing slot reuse
with outer var bindings. When both got the same mangled name, the Annex B
assignment would overwrite the outer variable at runtime, changing program
behavior.
The fix has two parts:
1. Annex B function declarations in sloppy block scopes are partitioned to
the end of bindings and always get fresh slots (never reuse existing ones)
2. Their slot liveness is extended upward to the enclosing var-scope,
preventing sibling scopes from reusing their slots either
Strict mode and ES modules are unaffected (no Annex B, no size regression).
Fixes #20610
Fixes #14316
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
f548a0e to
9d7cb62
Compare
sapphi-red
left a comment
There was a problem hiding this comment.
I'm not sure why we need "Part 1". Isn't that we just need to treat function decls as a var decl? If not, why is "Part 1" not needed for var decls?
Great question — you're right that Part 2 is unnecessary. I've removed it. Why oxc/crates/oxc_semantic/src/binder.rs Lines 46 to 98 in 9d7cb62 So the mangler sees Why Annex B functions need Part 1: Semantic does NOT hoist them — they stay in the block scope's bindings. The mangler processes scopes in DFS order (parent before children). When it reaches the block scope, it finds the outer I verified Part 2 is not needed by removing it — all tests pass including new edge cases for sibling scopes (let, catch, sibling Annex B functions). Sibling bindings reusing the function's slot is safe because they're truly block-scoped ( |
94fdb40 to
16f150c
Compare
Can we loop over the scopes once and generate the bindings list with the functions hoisted? It will require a bit of overhead, but I think that should allow reusing the variable names. |
I've tried the hoisting approach. Please see 14c83d6. It worked, but it looks complicated. I am trying to move the hoisting logic back to Semantic. |
…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

Summary
In sloppy mode JavaScript, function declarations inside blocks (
if,try,switch,{}) have Annex B.3.2.1 semantics: they create an implicitvar-like assignment in the enclosing function scope at block exit. The mangler was treating these as purely block-scoped (likelet/const), allowing slot reuse with outervarbindings. When both got the same mangled name, the Annex B assignment would overwrite the outer variable at runtime, changing program behavior.Before (broken)
After (fixed)
How the fix works
The mangler assigns short names by grouping variables into slots. Variables in the same slot get the same mangled name. Two variables can share a slot if their lifetimes don't overlap.
For
vardeclarations in blocks, this isn't a problem —oxc_semanticalready hoists them to the enclosing function scope (binder.rs lines 46-98), so the mangler sees them in the function scope's bindings and they naturally get unique slots.But for Annex B function declarations, semantic does NOT hoist them — they stay in the block scope. The mangler processes scopes in DFS order (parent before children):
var paramto slot 0e→ Annex B overwrites param at runtimeThe fix: When processing a sloppy-mode block scope (
!scope_flags.is_var() && !scope_flags.is_strict_mode()), partition bindings into two regions:Only the first
regular_countbindings are allowed to reuse existing slots (.take(regular_count)). Function declarations after that boundary always get fresh (new) slots, preventing them from reusing an outervar's slot.Sibling scopes reusing the Annex B function's fresh slot is safe — only
let/const/catch bindings can appear in sibling block scopes (varis hoisted), and these are truly block-scoped, so sharing a name just creates a harmless temporary shadow.Strict mode and ES modules are unaffected — the guard ensures no Annex B handling (and no size regression) for strict code.
Fixes #20610. Fixes #14316.
Downstream: vitejs/vite#22009, rolldown/rolldown#8791.
Test plan
if,try, plain blocks, parameters, nested blocks, arrow callbacks, multiple functions, cross-references, siblinglet, sibling Annex B functions, sibling catch parameters, and siblingvar(hoisted)