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:
Dunqing committed 2026-04-15 14:12:57 +00:00
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,
);
}