mirror of
https://github.com/oxc-project/oxc.git
synced 2026-11-06 17:34:25 +00:00
fix(ecmascript): treat update expressions as unconditionally side-effectful (#21456)
## Summary
`obj.prop++` performs an implicit `GetValue` + `ToNumeric` + `PutValue`. The `ToNumeric` coercion alone can invoke `valueOf`/`Symbol.toPrimitive` on the old value, and the read/write may trigger getters, setters, or Proxy traps.
Previously `UpdateExpression::may_have_side_effects` only checked `property_write_side_effects()` and treated the update as free when disabled — letting the minifier drop `counter.value++` even when `counter` is externally observable.
## Fix
Return `true` unconditionally for `UpdateExpression`. Terser, esbuild, Rollup, and SWC all hardcode `++`/`--` as unconditionally side-effectful — this matches their behavior, and mirrors how compound assignments like `a.b += 1` are already handled one impl above (`AssignmentExpression`).
## Impact
Under rolldown's tree-shake config (`propertyWriteSideEffects: false`), these are now correctly preserved instead of being dropped:
```js
counter.value++;
(class { static { ++counter.count; } });
class A { [counter.another++] = 123; }
```
## Reported
rolldown/rolldown#9094 ([comment](https://github.com/rolldown/rolldown/pull/9094#discussion_r3083594834))
This commit is contained in:
1 parent
56af2f4fe0
commit
d7a359a0cc
3 files changed
+60
-29
No files matched your search
@@ -896,25 +896,11 @@ impl<'a> MayHaveSideEffects<'a> for AssignmentExpression<'a> {
|
||||
}
|
||||
|
||||
impl<'a> MayHaveSideEffects<'a> for UpdateExpression<'a> {
|
||||
fn may_have_side_effects(&self, ctx: &impl MayHaveSideEffectsContext<'a>) -> bool {
|
||||
if ctx.property_write_side_effects() {
|
||||
return true;
|
||||
}
|
||||
// When property_write_side_effects is false, member expression updates
|
||||
// (e.g. obj.prop++, obj[key]--) are treated like property writes.
|
||||
// The update operation (ToNumeric + PutValue) is considered side-effect-free,
|
||||
// but the object/key evaluation may still have side effects.
|
||||
match &self.argument {
|
||||
SimpleAssignmentTarget::StaticMemberExpression(e) => {
|
||||
e.object.may_have_side_effects(ctx)
|
||||
}
|
||||
SimpleAssignmentTarget::ComputedMemberExpression(e) => {
|
||||
e.object.may_have_side_effects(ctx) || e.expression.may_have_side_effects(ctx)
|
||||
}
|
||||
SimpleAssignmentTarget::PrivateFieldExpression(e) => {
|
||||
e.object.may_have_side_effects(ctx)
|
||||
}
|
||||
_ => true,
|
||||
}
|
||||
fn may_have_side_effects(&self, _ctx: &impl MayHaveSideEffectsContext<'a>) -> bool {
|
||||
// `++`/`--` performs an implicit GetValue + ToNumeric + PutValue — the
|
||||
// ToNumeric coercion alone can invoke `valueOf`/`Symbol.toPrimitive`.
|
||||
// Terser, esbuild, Rollup, and SWC all treat updates as unconditionally
|
||||
// side-effectful; match that.
|
||||
true
|
||||
}
|
||||
}
|
||||
@@ -1450,12 +1450,12 @@ fn test_property_write_side_effects_support() {
|
||||
test_with_ctx("a['b'] += 1", &no_write_ctx, true);
|
||||
test_with_ctx("a.#b += 1", &no_write_ctx, true);
|
||||
|
||||
// Update expressions have an implicit read
|
||||
test_with_ctx("a.b++", &no_write_ctx, false);
|
||||
test_with_ctx("a.b--", &no_write_ctx, false);
|
||||
test_with_ctx("++a.b", &no_write_ctx, false);
|
||||
test_with_ctx("a['b']++", &no_write_ctx, false);
|
||||
test_with_ctx("a.#b++", &no_write_ctx, false);
|
||||
// Update expressions have an implicit read — side-effectful when reads have side effects
|
||||
test_with_ctx("a.b++", &no_write_ctx, true);
|
||||
test_with_ctx("a.b--", &no_write_ctx, true);
|
||||
test_with_ctx("++a.b", &no_write_ctx, true);
|
||||
test_with_ctx("a['b']++", &no_write_ctx, true);
|
||||
test_with_ctx("a.#b++", &no_write_ctx, true);
|
||||
|
||||
// Compound assignments and updates always have side effects due to implicit coercions
|
||||
// (ToPrimitive/ToNumeric), even with both write and read side effects off
|
||||
@@ -1466,11 +1466,11 @@ fn test_property_write_side_effects_support() {
|
||||
};
|
||||
test_with_ctx("a.b = 1", &no_side_effects_ctx, false); // simple assign is free
|
||||
test_with_ctx("a.b += 1", &no_side_effects_ctx, true); // compound: ToNumeric coercion
|
||||
test_with_ctx("a.b++", &no_side_effects_ctx, false); // update: ToNumeric coercion
|
||||
test_with_ctx("a.b++", &no_side_effects_ctx, true); // update: ToNumeric coercion
|
||||
test_with_ctx("a['b'] += 1", &no_side_effects_ctx, true);
|
||||
test_with_ctx("a['b']++", &no_side_effects_ctx, false);
|
||||
test_with_ctx("a['b']++", &no_side_effects_ctx, true);
|
||||
test_with_ctx("a.#b += 1", &no_side_effects_ctx, true);
|
||||
test_with_ctx("a.#b++", &no_side_effects_ctx, false);
|
||||
test_with_ctx("a.#b++", &no_side_effects_ctx, true);
|
||||
|
||||
// Sub-expression side effects still propagate
|
||||
test_with_ctx("(foo()).b = 1", &no_side_effects_ctx, true);
|
||||
|
||||
@@ -674,3 +674,48 @@ fn test_property_write_side_effects() {
|
||||
CompressOptions { unused: CompressOptionsUnused::Remove, ..CompressOptions::smallest() };
|
||||
test_same_options("function A() {} A.from = () => {};", &default_opts);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_update_expression_respects_property_read_side_effects() {
|
||||
// `obj.prop++` performs an implicit read, so it's side-effectful when
|
||||
// `property_read_side_effects` is `All` — even if writes are free.
|
||||
let options = CompressOptions {
|
||||
unused: CompressOptionsUnused::Remove,
|
||||
treeshake: TreeShakeOptions {
|
||||
property_write_side_effects: false,
|
||||
property_read_side_effects: PropertyReadSideEffects::All,
|
||||
..TreeShakeOptions::default()
|
||||
},
|
||||
..CompressOptions::smallest()
|
||||
};
|
||||
|
||||
test_options(
|
||||
"import { counter } from './c'; counter.value++; console.log(counter);",
|
||||
"import { counter } from './c'; counter.value++, console.log(counter);",
|
||||
&options,
|
||||
);
|
||||
test_options(
|
||||
"import { counter } from './c'; ++counter.count; console.log(counter);",
|
||||
"import { counter } from './c'; ++counter.count, console.log(counter);",
|
||||
&options,
|
||||
);
|
||||
test_options(
|
||||
"import { counter } from './c'; counter['another']--; console.log(counter);",
|
||||
"import { counter } from './c'; counter.another--, console.log(counter);",
|
||||
&options,
|
||||
);
|
||||
|
||||
// Static block runs on class evaluation.
|
||||
test_options(
|
||||
"import { counter } from './c'; (class { static { ++counter.count; } }); console.log(counter);",
|
||||
"import { counter } from './c'; (class { static { ++counter.count; } }), console.log(counter);",
|
||||
&options,
|
||||
);
|
||||
|
||||
// Computed key runs on class evaluation; class body is unused, so only the key's side effect is extracted.
|
||||
test_options(
|
||||
"import { counter } from './c'; class A { [counter.another++] = 123; } console.log(counter);",
|
||||
"import { counter } from './c'; counter.another++, console.log(counter);",
|
||||
&options,
|
||||
);
|
||||
}
|
||||
Reference in new issue
Block a user