fix(minifier): preserve var inside catch with same-named parameter (#21366)

## Summary

- Block inlining of `var` declarators whose symbol has the `CatchVariable` flag, preventing loss of function-scoped hoisting
- Check `symbol_redeclarations` before removing catch parameters, preventing semantic changes when the catch body contains a `var` redeclaration
- Uses existing semantic data (`CatchVariable` flag and `symbol_redeclarations`) instead of manual AST walks

Closes #17307

## Test plan

- [x] `cargo test -p oxc_minifier` — 476 tests pass
- [x] Verified minified output matches original JS semantics via `node`

🤖 Generated with [Claude Code](https://claude.com/claude-code)
This commit is contained in:
Dunqing committed 2026-04-15 02:57:05 +00:00
1 parent b5cb8d1fc4
commit b0e8f13889
4 files changed
+51 -12

No files matched your search

@@ -1250,6 +1250,13 @@ impl<'a> PeepholeOptimizations {
let BindingPattern::BindingIdentifier(prev_decl_id) = &prev_decl.id else {
return true;
};
// Don't inline `var e` inside `catch (e) { ... }`. Removing the var declarator
// would lose the function-scoped hoisting that `var` provides. The catch parameter
// and the var share one symbol (with CatchVariable flag) due to the redeclaration
// semantics in https://tc39.es/ecma262/#sec-variablestatements-in-catch-blocks
if ctx.scoping().symbol_flags(prev_decl_id.symbol_id()).is_catch_variable() {
return true;
}
if ctx.is_expression_whose_name_needs_to_be_kept(prev_decl_init) {
return true;
}
@@ -1581,6 +1581,10 @@ impl<'a> PeepholeOptimizations {
&& let Some(param) = &catch.param
&& let BindingPattern::BindingIdentifier(ident) = &param.pattern
&& (catch.body.body.is_empty() || ctx.scoping().symbol_is_unused(ident.symbol_id()))
// Don't remove catch parameter when the body has a `var` with the same name.
// In `catch (e) { var e = x }`, `var e` hoists to function scope but the assignment
// targets the catch parameter. Removing the catch param changes semantics.
&& ctx.scoping().symbol_redeclarations(ident.symbol_id()).is_empty()
{
catch.param = None;
}
@@ -2343,7 +2343,11 @@ fn test_remove_dead_expr_other() {
"try { throw 1 } catch (x) { y(x); var x = 2; y(x) }",
"try { throw 1;} catch (x) { y(x); var x = 2; y(x);}",
);
test("try { throw 1 } catch (x) { var x = 2; y(x) }", "try { throw 1;} catch { y(2);}");
// `var x` inside `catch (x)` must be kept, because removing it loses hoisting
test(
"try { throw 1 } catch (x) { var x = 2; y(x) }",
"try { throw 1;} catch (x) { var x = 2; y(x);}",
);
test(
"try { throw 1 } catch (x) { var x = 2; y(x) } console.log(x)",
"try { throw 1;} catch (x) { var x = 2; y(x);} console.log(x)",
@@ -740,19 +740,43 @@ fn optional_catch_binding() {
test_same("try { foo } catch([e]) {}");
test_same("try { foo } catch({e}) {}");
test_same("try { foo } catch(e) { var e = baz; bar(e) }");
test("try { foo } catch(e) { var e = 2 }", "try { foo } catch { var e = 2 }");
// catch param must be kept when body has a same-named var. Removing
// the param would change which binding the assignment targets.
test_same("try { foo } catch(e) { var e = 2 }");
test_same("try { foo } catch(e) { var e = 2 } bar(e)");
test_same("try { foo } catch(e) { var {e} = obj }");
test_same("try { foo } catch(e) { var [e] = arr }");
// FIXME catch(a) has no references but it cannot be removed.
// test_same(
// r#"var a = "PASS";
// try {
// throw "FAIL1";
// } catch (a) {
// var a = "FAIL2";
// }
// console.log(a);"#,
// );
// var inside a function does NOT interact with the catch parameter;
// var doesn't hoist out of functions, so the catch param can be removed.
test(
"try { foo } catch(e) { (function() { var e = 2 })() }",
"try { foo } catch { (function() { var e = 2;})();}",
);
test(
"try { foo } catch(e) { function f() { var e = 2 } }",
"try { foo } catch { function f() { var e = 2 } }",
);
test_same(
r#"var a = "PASS";
try {
throw "FAIL1";
} catch (a) {
var a = "FAIL2";
}
console.log(a);"#,
);
// Regression tests for https://github.com/oxc-project/oxc/issues/17307
test(
"try {} catch (e) { try {} catch (e) { var e = 'e'; console.log(e === 'e') } } console.log(e === undefined)",
"try {} catch (e) { var e } console.log(e === void 0)",
);
test(
"try { throw 1 } catch (e) { try { throw 2 } catch (e) { var e = 'e'; console.log(e === 'e') } } console.log(e === undefined)",
"try { throw 1 } catch (e) { try { throw 2 } catch (e) { var e = 'e'; console.log(e === 'e') } } console.log(e === void 0)",
);
test_target_same("try { foo } catch(e) {}", "chrome65");
}