Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
52 changes: 50 additions & 2 deletions crates/oxc_minifier/tests/mangler/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -6,9 +6,12 @@ use oxc_mangler::{MangleOptions, MangleOptionsKeepNames, Mangler};
use oxc_parser::Parser;
use oxc_span::SourceType;

fn mangle(source_text: &str, options: MangleOptions) -> String {
fn mangle_with_source_type(
source_text: &str,
options: MangleOptions,
source_type: SourceType,
) -> String {
let allocator = Allocator::default();
let source_type = SourceType::mjs().with_unambiguous(true);
let ret = Parser::new(&allocator, source_text, source_type).parse();
assert!(ret.errors.is_empty(), "Parser errors: {:?}", ret.errors);
let program = ret.program;
Expand All @@ -20,6 +23,14 @@ fn mangle(source_text: &str, options: MangleOptions) -> String {
.code
}

fn mangle(source_text: &str, options: MangleOptions) -> String {
mangle_with_source_type(source_text, options, SourceType::mjs().with_unambiguous(true))
}

fn mangle_script(source_text: &str, options: MangleOptions) -> String {
mangle_with_source_type(source_text, options, SourceType::script())
}

fn test(source_text: &str, expected: &str, options: MangleOptions) {
let mangled = mangle(source_text, options);
let expected = {
Expand Down Expand Up @@ -207,3 +218,40 @@ fn private_member_mangling() {
insta::assert_snapshot!("private_member_mangling", snapshot);
});
}

/// Annex B.3.2.1: In sloppy mode, function declarations inside blocks have var-like hoisting.
/// The mangler must not assign the same name to such a function and an outer `var` binding.
#[test]
fn annex_b_block_scoped_function() {
let cases = [
// Core bug: var + block function in if statement (vitejs/vite#22009)
"function _() { var x = 1; if (true) { function y() {} } use(x); }",
// var + block function in try block (oxc-project/oxc#14316)
"function _() { var x = 1; try { function y() {} } finally {} use(x); }",
// var + block function in plain block
"function _() { var x = 1; { function y() {} } use(x); }",
// Parameter + block function
"function _(x) { if (true) { function y() {} } use(x); }",
// Deeply nested blocks
"function _() { var x = 1; { { if (true) { function y() {} } } } use(x); }",
// Multiple block functions in same scope
"function _() { var x = 1; if (true) { function y() {} function z() {} } use(x); }",
// Block function referencing outer var
"function _() { var x = 1; if (true) { function y() { return x; } } use(x); }",
// Annex B function reuses name from sibling function scope (hoisting enables this)
"function _() { function foo() { var x; use(x); } function bar() { if (true) { function baz() {} use(baz); } } }",
// typeof must not be replaced with a constant (reviewer request)
"console.log(typeof foo); if (true) { function foo() { return 1; } }",
];

let mut snapshot = String::new();
cases.into_iter().fold(&mut snapshot, |w, case| {
let options = MangleOptions::default();
write!(w, "{case}\n{}\n", mangle_script(case, options)).unwrap();
w
});

insta::with_settings!({ prepend_module_to_snapshot => false, omit_expression => true }, {
insta::assert_snapshot!("annex_b_block_scoped_function", snapshot);
});
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,93 @@
---
source: crates/oxc_minifier/tests/mangler/mod.rs
---
function _() { var x = 1; if (true) { function y() {} } use(x); }
function _() {
var e = 1;
if (true) {
function t() {}
}
use(e);
}

function _() { var x = 1; try { function y() {} } finally {} use(x); }
function _() {
var e = 1;
try {
function t() {}
} finally {}
use(e);
}

function _() { var x = 1; { function y() {} } use(x); }
function _() {
var e = 1;
{
function t() {}
}
use(e);
}

function _(x) { if (true) { function y() {} } use(x); }
function _(e) {
if (true) {
function t() {}
}
use(e);
}

function _() { var x = 1; { { if (true) { function y() {} } } } use(x); }
function _() {
var e = 1;
{
{
if (true) {
function t() {}
}
}
}
use(e);
}

function _() { var x = 1; if (true) { function y() {} function z() {} } use(x); }
function _() {
var e = 1;
if (true) {
function t() {}
function n() {}
}
use(e);
}

function _() { var x = 1; if (true) { function y() { return x; } } use(x); }
function _() {
var e = 1;
if (true) {
function t() {
return e;
}
}
use(e);
}

function _() { function foo() { var x; use(x); } function bar() { if (true) { function baz() {} use(baz); } } }
function _() {
function e() {
var e;
use(e);
}
function t() {
if (true) {
function e() {}
use(e);
}
}
}

console.log(typeof foo); if (true) { function foo() { return 1; } }
console.log(typeof foo);
if (true) {
function foo() {
return 1;
}
}
31 changes: 31 additions & 0 deletions crates/oxc_semantic/src/binder.rs
Original file line number Diff line number Diff line change
Expand Up @@ -152,6 +152,37 @@ impl<'a> Binder<'a> for Function<'a> {
let symbol_id = builder.declare_symbol(ident.span, ident.name, includes, excludes);
ident.symbol_id.set(Some(symbol_id));

// Annex B.3.2.1: In sloppy mode, plain function declarations inside block
// scopes also create an implicit var-like binding in the enclosing function
// scope. Hoist to the var scope — same pattern as var hoisting (line 46).
let scope_flags = builder.current_scope_flags();
if is_declaration // function expressions are bound in their own (var) scope
&& !self.r#async // Annex B only applies to plain functions
&& !self.generator // not generators or async generators
&& !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
{
let block_scope_id = builder.current_scope_id;
let var_scope_id = builder
.scoping
.scope_ancestors(block_scope_id)
.skip(1)
.find(|&id| builder.scoping.scope_flags(id).is_var());
if let Some(var_scope_id) = var_scope_id
&& !builder.scoping.scope_has_binding(var_scope_id, ident.name)
{
builder.scoping.move_binding(block_scope_id, var_scope_id, ident.name);
builder.scoping.set_symbol_scope_id(symbol_id, var_scope_id);
builder
.hoisting_variables
.entry(block_scope_id)
.or_default()
.insert(ident.name, symbol_id);
}
}

// Save `@__NO_SIDE_EFFECTS__`
if self.pure {
builder.scoping.no_side_effects.insert(symbol_id);
Expand Down
4 changes: 4 additions & 0 deletions crates/oxc_semantic/src/builder.rs
Original file line number Diff line number Diff line change
Expand Up @@ -84,6 +84,10 @@ pub struct SemanticBuilder<'a> {
pub(crate) current_function_node_id: NodeId,
pub(crate) module_instance_state_cache: FxHashMap<Address, ModuleInstanceState>,
current_reference_flags: ReferenceFlags,
/// Symbols that have been hoisted out of a scope (e.g. `var` declarations hoisted to
/// the enclosing function scope, or Annex B function declarations hoisted to the var scope).
/// Keyed by the **original** scope the symbol was declared in, so that future declarations
/// in that scope can still detect redeclarations via `check_redeclaration`.
pub(crate) hoisting_variables: FxHashMap<ScopeId, IdentHashMap<'a, SymbolId>>,

// builders
Expand Down
Loading
Loading