mirror of
https://github.com/oxc-project/oxc.git
synced 2026-11-04 10:29:58 +00:00
fix(minifier): fold optional chains by base nullishness (#22236)
`fold_chain_expr` only operated on the outermost element of a
`ChainExpression` and only handled the *nullish* case, leaving two gaps:
* Non-nullish bases like `({})?.foo` were not folded. This isn't fixed
by #21923
* Multi-level chains like `null?.foo.bar` and `[]?.foo.bar` kept their
`?.` because the optional is on an inner element, not the outermost.
This it the focus for this PR.
The implemented fix rewrites the fold to walk inward to the *deepest* (=
leftmost in source) optional and act there. A small `ChainFoldResult`
enum encodes the three outcomes:
- `None`: no fold applies.
- `Flipped`: non-nullish base; `optional` flag was set to `false`.
Caller unwraps the `ChainExpression` if no other optionals remain.
- `Collapse { base, has_side_effects }`: nullish base; the chain
short-circuits to `void 0`. The base is taken out so its side effects
can be preserved via a sequence expression.
### Cases now covered
| Input | Output |
|---|---|
| `null?.foo` | `void 0` (worked before) |
| `null?.foo.bar` | `void 0` (new — nested nullish) |
| `null?.foo()` | `void 0` (new) |
| `(foo(), null)?.bar.baz` | `(foo(), void 0)` (new, side effects
preserved) |
| `({})?.foo` | `({}).foo` (new — #21923) |
| `(\"\")?.foo` | `(\"\").foo` (new) |
| `[]?.foo.bar` | `[].foo.bar` (new, nested non-nullish) |
| `(() => 0)?.()` | `0` (chains with IIFE inliner) |
| `({})?.foo?.bar` | `({}).foo?.bar` (inner flips, outer keeps the chain
wrapped) |
| `Number?.POSITIVE_INFINITY?.foo` | `Number?.POSITIVE_INFINITY.foo` |
| `b?.foo`, `foo()?.bar`, `new Foo()?.bar` | unchanged (unknown bases) |
Closes #21923
---------
Co-authored-by: armano2 <625469+armano2@users.noreply.github.com>
Co-authored-by: sapphi-red <49056869+sapphi-red@users.noreply.github.com>
Co-authored-by: Dunqing <dengqing0821@gmail.com>
This commit is contained in:
3 files changed
+227
-24
No files matched your search
@@ -75,33 +75,27 @@ impl<'a> PeepholeOptimizations {
|
||||
pub fn fold_chain_expr(expr: &mut Expression<'a>, ctx: &mut TraverseCtx<'a>) {
|
||||
let Expression::ChainExpression(e) = expr else { return };
|
||||
let span = e.span;
|
||||
let left_expr = match &mut e.expression {
|
||||
match_member_expression!(ChainElement) => {
|
||||
let member_expr = e.expression.to_member_expression_mut();
|
||||
if !member_expr.optional() {
|
||||
return;
|
||||
match try_fold_chain_at_element(&mut e.expression, ctx) {
|
||||
ChainFold::Unfolded { .. } => {}
|
||||
ChainFold::Flipped { has_optional } => {
|
||||
// For `(known_obj)?.foo?.bar` the inner `?.` flips, but the
|
||||
// outer `?.bar` keeps the wrapper alive.
|
||||
if !has_optional {
|
||||
*expr = Expression::from(e.expression.take_in(ctx.ast));
|
||||
}
|
||||
member_expr.object_mut()
|
||||
ctx.state.changed = true;
|
||||
}
|
||||
ChainElement::CallExpression(call_expr) => {
|
||||
if !call_expr.optional {
|
||||
return;
|
||||
}
|
||||
&mut call_expr.callee
|
||||
ChainFold::Collapse { base, base_has_side_effects } => {
|
||||
*expr = if base_has_side_effects {
|
||||
ctx.ast.expression_sequence(
|
||||
span,
|
||||
ctx.ast.vec_from_array([base, ctx.ast.void_0(span)]),
|
||||
)
|
||||
} else {
|
||||
ctx.value_to_expr(span, ConstantValue::Undefined)
|
||||
};
|
||||
ctx.state.changed = true;
|
||||
}
|
||||
ChainElement::TSNonNullExpression(_) => return,
|
||||
};
|
||||
let ty = left_expr.value_type(ctx);
|
||||
if ty.is_null() || ty.is_undefined() {
|
||||
*expr = if left_expr.may_have_side_effects(ctx) {
|
||||
ctx.ast.expression_sequence(
|
||||
span,
|
||||
ctx.ast.vec_from_array([left_expr.take_in(ctx.ast), ctx.ast.void_0(span)]),
|
||||
)
|
||||
} else {
|
||||
ctx.value_to_expr(span, ConstantValue::Undefined)
|
||||
};
|
||||
ctx.state.changed = true;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -773,3 +767,130 @@ impl<'a> PeepholeOptimizations {
|
||||
ctx.state.changed = true;
|
||||
}
|
||||
}
|
||||
|
||||
/// Outcome of folding an optional-chain at the deepest optional position.
|
||||
///
|
||||
/// `has_optional` carries the same notion in both `Unfolded` and `Flipped`:
|
||||
/// whether any unresolved `?.` exists in the chain segment seen so far —
|
||||
/// below the current level while still searching, above the fold point
|
||||
/// after firing. It gates outer fold attempts and the wrapper unwrap.
|
||||
enum ChainFold<'a> {
|
||||
Unfolded {
|
||||
has_optional: bool,
|
||||
},
|
||||
Flipped {
|
||||
has_optional: bool,
|
||||
},
|
||||
/// `base_has_side_effects` is computed here so the caller doesn't walk
|
||||
/// the base subtree a second time.
|
||||
Collapse {
|
||||
base: Expression<'a>,
|
||||
base_has_side_effects: bool,
|
||||
},
|
||||
}
|
||||
|
||||
/// Try folding the *deepest* (= leftmost in source order) optional in a
|
||||
/// chain. Recurses inward through `.object` / `.callee` first so the
|
||||
/// short-circuit point is found before any outer access.
|
||||
///
|
||||
/// Nested `ChainExpression`s (e.g. `(a?.b)?.c`) are not descended into —
|
||||
/// the compressor's separate flattening pass merges them into a single
|
||||
/// chain on a later iteration, after which this fold fires.
|
||||
fn try_fold_chain_at_element<'a>(
|
||||
elem: &mut ChainElement<'a>,
|
||||
ctx: &TraverseCtx<'a>,
|
||||
) -> ChainFold<'a> {
|
||||
match elem {
|
||||
ChainElement::CallExpression(c) => try_fold_call_expression(c, ctx),
|
||||
match_member_expression!(ChainElement) => {
|
||||
try_fold_member_expression(elem.to_member_expression_mut(), ctx)
|
||||
}
|
||||
ChainElement::TSNonNullExpression(t) => try_fold_chain_at_expr(&mut t.expression, ctx),
|
||||
}
|
||||
}
|
||||
|
||||
fn try_fold_chain_at_expr<'a>(expr: &mut Expression<'a>, ctx: &TraverseCtx<'a>) -> ChainFold<'a> {
|
||||
match expr.get_inner_expression_mut() {
|
||||
Expression::CallExpression(c) => try_fold_call_expression(c, ctx),
|
||||
match_member_expression!(Expression) => {
|
||||
try_fold_member_expression(expr.to_member_expression_mut(), ctx)
|
||||
}
|
||||
_ => ChainFold::Unfolded { has_optional: false },
|
||||
}
|
||||
}
|
||||
|
||||
fn try_fold_call_expression<'a>(
|
||||
call: &mut CallExpression<'a>,
|
||||
ctx: &TraverseCtx<'a>,
|
||||
) -> ChainFold<'a> {
|
||||
match try_fold_chain_at_expr(&mut call.callee, ctx) {
|
||||
ChainFold::Flipped { has_optional } => {
|
||||
ChainFold::Flipped { has_optional: has_optional || call.optional }
|
||||
}
|
||||
collapse @ ChainFold::Collapse { .. } => collapse,
|
||||
ChainFold::Unfolded { has_optional } => {
|
||||
try_fold_at_optional(&mut call.optional, &mut call.callee, has_optional, ctx)
|
||||
.unwrap_or(ChainFold::Unfolded { has_optional: has_optional || call.optional })
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn try_fold_member_expression<'a>(
|
||||
member: &mut MemberExpression<'a>,
|
||||
ctx: &TraverseCtx<'a>,
|
||||
) -> ChainFold<'a> {
|
||||
let (optional_mut, object) = member_expression_optional_and_object_mut(member);
|
||||
let optional = *optional_mut;
|
||||
match try_fold_chain_at_expr(object, ctx) {
|
||||
ChainFold::Flipped { has_optional } => {
|
||||
ChainFold::Flipped { has_optional: has_optional || optional }
|
||||
}
|
||||
collapse @ ChainFold::Collapse { .. } => collapse,
|
||||
ChainFold::Unfolded { has_optional } => {
|
||||
try_fold_at_optional(optional_mut, object, has_optional, ctx)
|
||||
.unwrap_or(ChainFold::Unfolded { has_optional: has_optional || optional })
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn member_expression_optional_and_object_mut<'a, 'b>(
|
||||
member: &'b mut MemberExpression<'a>,
|
||||
) -> (&'b mut bool, &'b mut Expression<'a>) {
|
||||
match member {
|
||||
MemberExpression::StaticMemberExpression(m) => {
|
||||
let m = &mut **m;
|
||||
(&mut m.optional, &mut m.object)
|
||||
}
|
||||
MemberExpression::ComputedMemberExpression(m) => {
|
||||
let m = &mut **m;
|
||||
(&mut m.optional, &mut m.object)
|
||||
}
|
||||
MemberExpression::PrivateFieldExpression(m) => {
|
||||
let m = &mut **m;
|
||||
(&mut m.optional, &mut m.object)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn try_fold_at_optional<'a>(
|
||||
optional: &mut bool,
|
||||
base: &mut Expression<'a>,
|
||||
has_optional: bool,
|
||||
ctx: &TraverseCtx<'a>,
|
||||
) -> Option<ChainFold<'a>> {
|
||||
if !*optional || has_optional {
|
||||
return None;
|
||||
}
|
||||
match base.value_type(ctx) {
|
||||
ValueType::Null | ValueType::Undefined => {
|
||||
let base_has_side_effects = base.may_have_side_effects(ctx);
|
||||
let taken = base.take_in(ctx.ast);
|
||||
Some(ChainFold::Collapse { base: taken, base_has_side_effects })
|
||||
}
|
||||
ValueType::Undetermined => None,
|
||||
_ => {
|
||||
*optional = false;
|
||||
Some(ChainFold::Flipped { has_optional: false })
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -575,3 +575,17 @@ fn preserve_pure_iife_in_used_position_for_downstream_treeshake() {
|
||||
|
||||
test("export const x = /* @__PURE__ */ (() => 42)();", "export const x = 42;");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn drop_optional_chain_on_non_nullish_base() {
|
||||
// https://github.com/oxc-project/oxc/issues/21923
|
||||
// ObjectExpression at statement start needs to stay parenthesised; codegen
|
||||
// already handles that, so the fold is safe in statement position.
|
||||
test("({})?.foo;", "({}).foo;");
|
||||
// Side effects on the base are preserved when the `?.` is dropped.
|
||||
test("export const v = (foo(), {})?.bar", "export const v = (foo(), {}).bar");
|
||||
// Nested: the optional is on the inner access, the outer access is
|
||||
// non-optional. Both folds (collapse + drop) reach the deepest `?.`.
|
||||
test("export const v = null?.foo.bar", "export const v = void 0");
|
||||
test("export const v = []?.foo.bar", "export const v = [].foo.bar");
|
||||
}
|
||||
@@ -686,6 +686,74 @@ fn test_fold_opt_chain() {
|
||||
fold("x = null?.()", "x = void 0");
|
||||
fold("x = (foo(), null)?.y", "x = (foo(), void 0)");
|
||||
fold("x = (foo(), null)?.()", "x = (foo(), void 0)");
|
||||
|
||||
// Nested: nullish base short-circuits the entire chain even when the
|
||||
// optional is not on the outermost element.
|
||||
fold("x = null?.foo.bar", "x = void 0");
|
||||
fold("x = ((null))?.foo", "x = void 0");
|
||||
fold("x = null?.foo()", "x = void 0");
|
||||
fold("x = null?.foo.bar.baz()", "x = void 0");
|
||||
fold("x = (foo(), null)?.bar.baz", "x = (foo(), void 0)");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_fold_opt_chain_non_nullish_base() {
|
||||
// https://github.com/oxc-project/oxc/issues/21923
|
||||
// Drop `?.` when the base is statically non-nullish.
|
||||
fold(r#"x = ("")?.foo"#, r#"x = ("").foo"#);
|
||||
fold("x = (1)?.foo", "x = (1).foo");
|
||||
fold("x = (1n)?.foo", "x = (1n).foo");
|
||||
fold("x = ({})?.foo", "x = ({}).foo");
|
||||
fold("x = ([])?.foo", "x = ([]).foo");
|
||||
fold("x = (() => 0)?.foo", "x = (() => 0).foo");
|
||||
fold("x = (function () {})?.foo", "x = (function () {}).foo");
|
||||
fold("x = (class {})?.foo", "x = (class {}).foo");
|
||||
fold("x = /a/?.flags", "x = /a/.flags");
|
||||
// Computed and optional-call forms.
|
||||
fold(r#"x = ({})?.["foo"]"#, "x = ({}).foo");
|
||||
// Fold chains with the IIFE inliner: (() => 0)?.() -> (() => 0)() -> 0
|
||||
fold("x = (() => 0)?.()", "x = 0");
|
||||
|
||||
// Side effects on the base must be preserved.
|
||||
fold("x = (foo(), {})?.bar", "x = (foo(), {}).bar");
|
||||
|
||||
// The outer `?.foo` cannot be dropped while the base still contains an
|
||||
// unresolved optional. `Number` may be shadowed by a primitive, in which
|
||||
// case `Number?.POSITIVE_INFINITY.foo` would throw.
|
||||
fold_same("x = Number?.POSITIVE_INFINITY?.foo");
|
||||
fold_same("x = Number?.NEGATIVE_INFINITY?.foo");
|
||||
test(
|
||||
"const Number = 1; x = Number?.POSITIVE_INFINITY?.foo",
|
||||
"const Number = 1; x = 1 .POSITIVE_INFINITY?.foo",
|
||||
);
|
||||
test(
|
||||
"const Number = 1; x = Number?.POSITIVE_INFINITY?.[foo()]",
|
||||
"const Number = 1; x = 1 .POSITIVE_INFINITY?.[foo()]",
|
||||
);
|
||||
test(
|
||||
"const Number = 1; x = Number?.POSITIVE_INFINITY?.(foo())",
|
||||
"const Number = 1; x = 1 .POSITIVE_INFINITY?.(foo())",
|
||||
);
|
||||
|
||||
// Unknown bases are left alone.
|
||||
fold_same("x = b?.foo");
|
||||
fold_same("x = foo()?.bar");
|
||||
fold_same("x = new Foo()?.bar");
|
||||
|
||||
// Nested chains: drop the inner `?.` when its base is non-nullish, even
|
||||
// when the outermost element is non-optional.
|
||||
fold("x = []?.foo.bar", "x = [].foo.bar");
|
||||
fold(r#"x = ("")?.foo.bar"#, r#"x = ("").foo.bar"#);
|
||||
fold("x = ({})?.foo()", "x = ({}).foo()");
|
||||
fold(r#"x = /a/?.test("a")"#, r#"x = /a/.test("a")"#);
|
||||
|
||||
// Inner `?.` flips, outer `?.` keeps the chain wrapped.
|
||||
fold("x = ({})?.foo?.bar", "x = ({}).foo?.bar");
|
||||
fold("x = (() => 0)?.foo?.()", "x = (() => 0).foo?.()");
|
||||
|
||||
// Nested ChainExpressions are flattened by a separate pass before this
|
||||
// fold sees the inner optional on a later compression iteration.
|
||||
fold("x = (({})?.foo)?.bar", "x = ({}).foo?.bar");
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
Reference in new issue
Block a user