feat(minifier): support property_write_side_effects option to drop unused property assignments#20773
Conversation
How to use the Graphite Merge QueueAdd either label to this PR to merge it via the merge queue:
You must have a Graphite account in order to use the merge queue. Sign up using this link. An organization admin has enabled the Graphite Merge Queue in this repository. Please do not merge from GitHub as this will restart CI on PRs being processed by the merge queue. This stack of pull requests is managed by Graphite. Learn more about stacking. |
Merging this PR will degrade performance by 3.43%
Performance Changes
Comparing Footnotes
|
ae2c858 to
075f2cd
Compare
8b2ca65 to
3ade183
Compare
2ffb46b to
abd48a1
Compare
3ade183 to
681dcf2
Compare
) ## Summary Add a new `MemberWriteTarget` bit to `ReferenceFlags` ~~Store member write target references in a separate `FxHashSet<ReferenceId>` on `Semantic` rather than adding a flag to `ReferenceFlags`.~~ This marks identifier references read as the object of a member expression in a simple assignment target position (e.g., `A` in `A.foo = 1`). - Set during `visit_member_expression` when flags indicate write-only context (simple `=`, not compound `+=` or update `++`) - Stored in `Semantic::member_write_references` (`FxHashSet<ReferenceId>`) - Queryable via `Semantic::is_member_write_reference(reference_id)` and `Semantic::member_write_references()` - Does not affect existing `ReferenceFlags` — no changes to the shared bitflags type ## Motivation Distinguishing "reads that exist only to serve a property write" from "real reads" is broadly useful: ### Minifier: dead code elimination of property-write-only symbols ```js function A() {} A.from = () => {} // A's only reference is a member write target // → both can be eliminated when property_write_side_effects: false ``` Without this info, the minifier sees `A` as "referenced" and cannot remove the function declaration. With it, the minifier knows all of `A`'s reads are property-write targets and can safely drop them. (See stacked PR #20773 — will be updated to use the new API in a follow-up) ### Linter: `no_unused_vars` improvement ```js const config = {}; config.debug = true; config.verbose = false; // config is never read — only mutated via property writes ``` Currently `config` is considered "used" because `config.debug = ...` creates a read reference. With member write target tracking, `no_unused_vars` could detect that `config`'s only reads are property-write targets and report it as unused. ### Linter: simplify `no_param_reassign` property detection `no_param_reassign` has a complex `is_modifying_property()` function that manually walks the ancestor chain to detect property modifications like `param.foo = 1`. With member write target tracking, this can be checked directly on the reference. ### Rolldown: replace `MemberExprIsWrite` traversal state Rolldown already maintains its own [`MemberExprIsWrite` flag](https://github.com/rolldown/rolldown/blob/eec7d7320cc991e1efd5ed8264d02c6889a223c3/crates/rolldown/src/ast_scanner/mod.rs#L130-L131) as local traversal state in the AST scanner to [detect member expressions in write context](https://github.com/rolldown/rolldown/blob/eec7d7320cc991e1efd5ed8264d02c6889a223c3/crates/rolldown/src/ast_scanner/impl_visit.rs#L55-L73). Having this on `Semantic` means Rolldown can consume it directly instead of re-detecting it during scanning. There's also a [TODO in Rolldown's treeshake options](https://github.com/rolldown/rolldown/blob/main/crates/rolldown_common/src/inner_bundler_options/types/treeshake.rs#L255-L259) to wire `property_write_side_effects` to oxc_minifier — our stacked PR #20773 provides that. ### General: escape analysis for local objects Any analysis that needs to know "is this object only mutated locally, never passed elsewhere?" benefits from knowing which reads are purely for property writes vs. reads that pass the value to other functions. ## Test plan - [x] Semantic snapshot tests updated - [x] Conformance snapshots updated - [x] Clippy clean 🤖 Generated with [Claude Code](https://claude.com/claude-code)
abd48a1 to
75663c0
Compare
681dcf2 to
232df97
Compare
d8b3872 to
498a43b
Compare
|
Hi @sapphi-red, I reimplemented #20730 in this PR and solved two cases you mentioned in that. Please take a look again. Thank you! |
We only need to keep the
I think this is incorrect. SWC does remove them (playground). |
ea4c121 to
b2b1685
Compare
You are right, our implementation now matches the SWC implementation |
b2b1685 to
b744bb4
Compare
7438f81 to
bfc48ff
Compare
sapphi-red
left a comment
There was a problem hiding this comment.
LGTM
I think we should test this option out in many places after we merge this and is released in Rolldown / Vite.
Merge activity
|
…unused property assignments (#20773) ## Summary Add `property_write_side_effects` option to `TreeShakeOptions` (default `true`). When set to `false`, property assignments like `A.from = () => {}` are considered side-effect-free, enabling the minifier to drop declarations and fresh value bindings with property assignments when unused. ### Examples ```js // Function declaration + property assignments → eliminated function A() {} A.from = () => {} A.to = () => {} // Output: (empty) // Class declaration + property assignment → eliminated class A {} A.foo = 1 // Output: (empty) // Const variable with arrow function → eliminated const A = () => {} A.foo = 1 // Output: (empty) // Const variable with object literal → eliminated const B = {} B.foo = 1 // Output: (empty) // Const variable with function expression → eliminated const C = function() {} C.foo = 1 // Output: (empty) ``` ### How it works 1. Wires the existing `property_write_side_effects` from `oxc_ecmascript`'s `MayHaveSideEffectsContext` through the minifier's compress options 2. For member expression assignments, checks `may_have_side_effects()` then `is_member_assign_to_unused_binding()` 3. `is_member_assign_to_unused_binding` verifies: - Single-level member expression only (`A.foo`, not `a.b.c`) - All references to the symbol have the `MemberWriteTarget` flag (from parent PR) - Symbol creates a fresh value (`is_fresh_value`) and is not exported 4. Fixed-point loop handles cascading: first pass drops assignments, `exit_program` cleans up references, second pass drops declarations ### Fresh value detection A symbol is considered "fresh" (cannot alias another binding and property writes are side-effect-free) if it is: - A **function declaration** - A **class declaration** without static setters, static accessor properties, or static property definitions with values - A variable initialized with an **array/arrow function/function expression** literal - A variable initialized with an **object literal** without setter/getter properties or property values containing setters/getters - A variable initialized with a **class expression** (same rules as class declarations above) Non-fresh values (e.g. `const b = a`) are conservatively preserved to prevent breaking aliased references. #### Setter/getter detection (addresses review feedback) Following how esbuild, Terser, Rollup, and SWC handle setter cases — all tools preserve property writes when setters are visible in the AST: - **Static setter methods**: `class A { static set foo(v) { ... } }` → not fresh - **Static accessor properties**: `class A { static accessor foo = 0 }` → not fresh (auto-generates getter+setter) - **Static property definitions with values**: `class A { static b = 0 }` → not fresh (matches SWC's conservative approach) - **Static property values containing setters/getters**: `class A { static b = { set x(v) { ... } } }` → not fresh - **Object literal setters/getters**: `{ set foo(v) { ... } }` or `{ get foo() { ... } }` → not fresh - **Nested setters/getters in object property values**: `{ bar: { set x(v) { ... } } }` → not fresh - **Non-static setters are fine**: `class A { set foo(v) { ... } }` → fresh (instance setters don't affect class-level property writes) ### Limitations **Only single-level member expressions are supported** (`A.foo`, not `a.b.c`). Deep nested assignments like `a.b.c = 1` are always preserved because intermediate properties could alias external objects: ```js const exported = {}; const a = { b: exported }; a.b.c = 1; // writes to exported.c — unsafe to drop! export { exported }; ``` Supporting deeper nesting would require full object graph tracking (like Rollup's `ObjectEntity` model), which is a much larger effort for marginal gain. ### Safety guards Addresses concerns raised in [PR #20730 review](#20730 (review)) and [comment](#20730 (comment)): - **Aliases**: `const b = a; b.add = 1; export { a }` → kept (not fresh, could mutate exported `a` through alias) - **Chained access**: `const b = { a }; b.a.add = 1; export { a }` → kept (only single-level member expressions allowed) - **Exported symbols**: `export function A() {} A.foo = 1;` → kept (observable by importers) - **Other reads**: `function A() {} A.foo = 1; console.log(A)` → kept (symbol has non-member-write references) - **Side-effectful RHS**: `function A() {} A.foo = sideEffect()` → kept (`may_have_side_effects` returns true) - **Globals**: `globalObj.foo = 1` → kept (no local binding) - **Script mode top-level**: top-level bindings in script mode are kept (they're globals visible to other scripts) - **Setters**: `class A { static set foo(v) { ... } } A.foo = 1` → kept (setter triggers side effects) - **Static properties**: `class A { static b = 0 } A.b = 1` → kept (matches SWC's conservative approach) ### Comparison with other tools | Tool | Approach | Option needed? | |------|----------|---------------| | **Rollup** | Structural aliasing analysis — tracks object identity through `LocalVariable.isReassigned` + `ObjectEntity` setter detection. Drops `A.foo = 1` automatically when `A` is a local unreassigned object with no setters. | No (always active) | | **Rolldown** | Adds explicit [`treeshake.propertyWriteSideEffects`](https://rolldown.rs/reference/InputOptions.treeshake#propertywritesideeffects) option (default `'always'`). Not in upstream Rollup. | Yes | | **Terser** | Checks `is_constant_expression` on the base object after unwrapping property chain. If the base is constant within scope, drops the assignment. | No (always active, but less precise) | | **esbuild** | All property assignments always preserved. Rationale: `Object.defineProperty` could install prototype setters. Only escape hatch is `/* @__PURE__ */` IIFE wrapping. | No option available | | **SWC** | `may_have_side_effects` returns `true` for all assignments unconditionally. Classes with static property definitions are conservatively preserved. | No option available | | **Oxc (this PR)** | Fresh value tracking (`is_fresh_value`) + `MemberWriteTarget` reference flags + explicit option. Closest to Rollup's aliasing analysis but gated behind an option like Rolldown. | Yes (`property_write_side_effects: false`) | Closes #14207 ## Test plan - [x] 20 test cases covering all safety scenarios including setter/getter edge cases - [x] All minifier tests pass (476/476) - [x] No minsize regressions - [x] Clippy clean 🤖 Generated with [Claude Code](https://claude.com/claude-code)
f6ef890 to
f134e24
Compare
### 💥 BREAKING CHANGES - 36cdc31 str: [**BREAKING**] Remove identity `FromIn` impl for `Ident` (#21251) (overlookmotel) - 382958a span: [**BREAKING**] Remove re-exports of string types from `oxc_span` crate (#21246) (overlookmotel) - c4aedfa str: [**BREAKING**] Add `static_ident!` macro (#21245) (overlookmotel) ### 🚀 Features - e7e1aea transformer/typescript: Add `optimize_enums` option for regular enum inlining (#20539) (Dunqing) - 679f57f transformer/typescript: Implement const enum inlining and declaration removal (#20508) (Dunqing) - 6dd061c semantic: Extend `MemberWriteTarget` to cover all property modification patterns (#21205) (Dunqing) - f134e24 minifier: Support `property_write_side_effects` option to drop unused property assignments (#20773) (Dunqing) - 75663c0 semantic: Add enum member value evaluation for const enum support (#20602) (Dunqing) - 3cfe8ed semantic: Add `MemberWriteTarget` flag to `ReferenceFlags` (#20772) (Dunqing) ### 🐛 Bug Fixes - af1a586 transformer/class-properties: Use correct property name when converting parameter properties (#21268) (Amal Jossy) - b43250a allocator: Move allocation tracking into `Bump` (#21342) (overlookmotel) - 36f505f allocator: `StringBuilder` use `Allocator::alloc_layout` (#21340) (overlookmotel) - 7a08a6f allocator: Fix allocation counting in `Allocator::alloc_concat_strs_array` (#21336) (overlookmotel) - 2338e28 ecmascript: Treat `this` as potentially having side effects (#21297) (sapphi-red) - bd8bd39 allocator: Remove unsafe hacks from `from_raw_parts` methods (#21283) (overlookmotel) - 8f4c340 allocator: Remove dangerous pointer const to mut cast (#21279) (overlookmotel) - aa9259f parser: Add missing error code for optional param diagnostic (#21258) (camc314) - 04b3c2f str: Fix unsound casting const pointers to mut pointers (#21242) (overlookmotel) - ceadf6c str: Make `Ident::from_raw` an unsafe function (#21241) (overlookmotel) - eab13b3 transformer/decorators: Avoid accessor storage name collisions (#21106) (Dunqing) - 07e8a30 transformer/react-refresh: Handle parenthesized variable initializers (#21047) (camc314) ### ⚡ Performance - c3ca6f6 allocator: `StringBuilder::from_strs_array_in` check for 0 length earlier (#21338) (overlookmotel) - c2422bb allocator: `Allocator::alloc_concat_strs_array` check for 0 length earlier (#21337) (overlookmotel) - 04b0fdc allocator: Mark `Allocator::alloc_layout` as `#[inline(always)]` (#21335) (overlookmotel) - 17aee9e allocator: Use `offset_from_unsigned` in `ChunkFooter::as_raw_parts` (#21280) (overlookmotel) - 61adedd minifier: Fix O(n²) perf on very many var decls (#21062) (Gunnlaugur Thor Briem) - addcd02 napi/parser, linter/plugins: Raw transfer deserializer for `Vec`s use shift instead of multiply where possible (#21142) (overlookmotel) - 3068ded napi/parser, linter/plugins: Shift before add when calculating positions in raw transfer deserializer (#21141) (overlookmotel) - eb400b8 napi/parser, linter/plugins: Remove `uint32` buffer view (#21140) (overlookmotel) - 2675085 napi/parser: Lazy deserialization use only `Int32Array` (#21139) (overlookmotel) - 5b35a53 napi/parser: Deserializing tokens use only `int32` array (#21138) (overlookmotel) - f163d10 parser: Tokens raw deserialization use `Int32Array` (#21137) (overlookmotel) - 7a86613 linter/plugins: Use `Int32Array`s for tokens and comments buffers (#21136) (overlookmotel) - 8c51121 napi/parser, linter/plugins: Raw transfer deserialize `Span` fields as `i32`s (#21135) (overlookmotel) - bc1bcdd napi/parser, linter/plugins: Inline trivial raw transfer field deserializers into node object definitions (#21134) (overlookmotel) - c0278ab napi/parser, linter/plugins: Use `Int32Array` in raw transfer deserializer (#21132) (overlookmotel) - 43482c7 linter/plugins: Use `>>` not `>>>` in binary search loops (#21129) (overlookmotel) ### 📚 Documentation - f5e1845 allocator: Upgrade headers in doc comments for `Bump` (#21263) (overlookmotel) - 2870174 allocator: Upper case `SAFETY` in comments (#21253) (overlookmotel) - 01bc269 str: Reformat `Ident` doc comments (#21240) (overlookmotel) - dd47359 allocator: Add doc comments for panics and errors (#21230) (overlookmotel)

Summary
Add
property_write_side_effectsoption toTreeShakeOptions(defaulttrue). When set tofalse, property assignments likeA.from = () => {}are considered side-effect-free, enabling the minifier to drop declarations and fresh value bindings with property assignments when unused.Examples
How it works
property_write_side_effectsfromoxc_ecmascript'sMayHaveSideEffectsContextthrough the minifier's compress optionsmay_have_side_effects()thenis_member_assign_to_unused_binding()is_member_assign_to_unused_bindingverifies:A.foo, nota.b.c)MemberWriteTargetflag (from parent PR)is_fresh_value) and is not exportedexit_programcleans up references, second pass drops declarationsFresh value detection
A symbol is considered "fresh" (cannot alias another binding and property writes are side-effect-free) if it is:
Non-fresh values (e.g.
const b = a) are conservatively preserved to prevent breaking aliased references.Setter/getter detection (addresses review feedback)
Following how esbuild, Terser, Rollup, and SWC handle setter cases — all tools preserve property writes when setters are visible in the AST:
class A { static set foo(v) { ... } }→ not freshclass A { static accessor foo = 0 }→ not fresh (auto-generates getter+setter)class A { static b = 0 }→ not fresh (matches SWC's conservative approach)class A { static b = { set x(v) { ... } } }→ not fresh{ set foo(v) { ... } }or{ get foo() { ... } }→ not fresh{ bar: { set x(v) { ... } } }→ not freshclass A { set foo(v) { ... } }→ fresh (instance setters don't affect class-level property writes)Limitations
Only single-level member expressions are supported (
A.foo, nota.b.c). Deep nested assignments likea.b.c = 1are always preserved because intermediate properties could alias external objects:Supporting deeper nesting would require full object graph tracking (like Rollup's
ObjectEntitymodel), which is a much larger effort for marginal gain.Safety guards
Addresses concerns raised in PR #20730 review and comment:
const b = a; b.add = 1; export { a }→ kept (not fresh, could mutate exportedathrough alias)const b = { a }; b.a.add = 1; export { a }→ kept (only single-level member expressions allowed)export function A() {} A.foo = 1;→ kept (observable by importers)function A() {} A.foo = 1; console.log(A)→ kept (symbol has non-member-write references)function A() {} A.foo = sideEffect()→ kept (may_have_side_effectsreturns true)globalObj.foo = 1→ kept (no local binding)class A { static set foo(v) { ... } } A.foo = 1→ kept (setter triggers side effects)class A { static b = 0 } A.b = 1→ kept (matches SWC's conservative approach)Comparison with other tools
LocalVariable.isReassigned+ObjectEntitysetter detection. DropsA.foo = 1automatically whenAis a local unreassigned object with no setters.treeshake.propertyWriteSideEffectsoption (default'always'). Not in upstream Rollup.is_constant_expressionon the base object after unwrapping property chain. If the base is constant within scope, drops the assignment.Object.definePropertycould install prototype setters. Only escape hatch is/* @__PURE__ */IIFE wrapping.may_have_side_effectsreturnstruefor all assignments unconditionally. Classes with static property definitions are conservatively preserved.is_fresh_value) +MemberWriteTargetreference flags + explicit option. Closest to Rollup's aliasing analysis but gated behind an option like Rolldown.property_write_side_effects: false)Closes #14207
Test plan
🤖 Generated with Claude Code