feat(tree-shaking): recognize pure global constructors and calls#13701
feat(tree-shaking): recognize pure global constructors and calls#13701
Conversation
Teach the side-effect detector about built-in pure globals so that unused `new Set()`, `new Map()`, `new Uint8Array()`, `Object.keys(x)`, `Array.isArray(x)`, `String(x)`, etc. can be tree-shaken. SWC's `may_have_side_effects` only recognized `Date` and empty fn/class callees; everything else was kept unnecessarily. The callee must resolve to an unresolved global (ctxt check) so shadowed bindings like `const Set = ...; new Set()` are preserved. Arguments are still recursively checked for purity via the existing `are_pure_args` / `is_pure_call_args` helpers.
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: f0ce1c562a
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
| | "freeze" | ||
| | "fromEntries" | ||
| | "is" | ||
| | "assign" |
There was a problem hiding this comment.
Remove Object.assign/freeze from pure member-call allowlist
Object.assign and Object.freeze mutate the object passed in, so they are observable even when the return value is unused. Because is_pure_member_call now classifies both as pure and is_pure_call_args only checks argument expression purity, tree-shaking can incorrectly drop calls like Object.assign(config, defaults) or Object.freeze(exportsObj), changing later reads/exports of that object.
Useful? React with 👍 / 👎.
| | "is" | ||
| | "assign" | ||
| ), | ||
| "Array" => matches!(prop.sym.as_str(), "isArray" | "from" | "of"), |
There was a problem hiding this comment.
Treat Array.from mapper invocations as side effects
Array.from is not side-effect free by default: it executes the iterable protocol and may call the optional mapper function for each element. Marking it as pure here lets DCE remove statements such as Array.from(items, log) when the result is unused, which drops the mapper/iterator side effects and changes runtime behavior.
Useful? React with 👍 / 👎.
There was a problem hiding this comment.
Pull request overview
This PR expands Rspack’s side-effect analysis to treat certain built-in global constructors and global/member calls as pure (when their arguments are considered pure), enabling more aggressive tree-shaking of unused expressions like new Map() and Object.keys(x). It also adds a new tree-shaking fixture to validate the intended behavior.
Changes:
- Add a “pure globals” fast-path for call expressions (direct globals and selected
Object.*/Array.*member calls). - Add a “pure globals” fast-path for
newexpressions with selected built-in constructors. - Introduce a new tree-shaking test case (
global-pure-new) to validate unused pure constructions/calls are eliminated while keeping shadowed/impure cases.
Reviewed changes
Copilot reviewed 4 out of 4 changed files in this pull request and generated 5 comments.
| File | Description |
|---|---|
crates/rspack_plugin_javascript/src/parser_plugin/side_effects_parser_plugin.rs |
Adds allowlists and fast-path checks for treating certain global calls/constructors as pure. |
tests/rspack-test/treeShakingCases/global-pure-new/app.js |
Adds a fixture module meant to test tree-shaking of pure globals + guards. |
tests/rspack-test/treeShakingCases/global-pure-new/index.js |
Imports/uses an exported marker to ensure the module is included in the bundle. |
tests/rspack-test/treeShakingCases/global-pure-new/rspack.config.js |
Enables optimization.sideEffects for the new tree-shaking fixture. |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| | "is" | ||
| | "assign" | ||
| ), | ||
| "Array" => matches!(prop.sym.as_str(), "isArray" | "from" | "of"), |
There was a problem hiding this comment.
Including Array.from in the pure member-call allowlist is not safe: Array.from(iterable, mapFn) can invoke user-provided iteration and the optional mapping function, producing side effects even when the argument expressions themselves are “pure” (e.g. a pure arrow/function expression). Consider removing from from the allowlist or only treating it as pure for the 1-arg form when the input is a trivially safe literal (and no mapFn/thisArg).
| "Array" => matches!(prop.sym.as_str(), "isArray" | "from" | "of"), | |
| "Array" => matches!(prop.sym.as_str(), "isArray" | "of"), |
| const ShadowedSet = sideEffect(); | ||
| let shadowed = new ShadowedSet(); |
There was a problem hiding this comment.
The “Shadowed — must NOT be treated as pure” test doesn’t actually shadow a built-in global: ShadowedSet is a new identifier and will never match the Set/Map allowlist, so this doesn’t exercise the ctxt == unresolved_ctxt shadowing guard. Consider shadowing the real name instead (e.g. const Set = sideEffect(); new Set()) so this case fails if the unresolved-global check is broken.
| const ShadowedSet = sideEffect(); | |
| let shadowed = new ShadowedSet(); | |
| const Set = sideEffect(); | |
| let shadowed = new Set(); |
| // Fast path: known pure global constructors (`new Set()`, `new Map()`, | ||
| // `new WeakMap()`, TypedArrays, etc.). SWC's `is_pure_new_callee` only | ||
| // recognizes empty functions and pure class expressions, so without this | ||
| // fast path `let m = new Map()` is kept even when unused. | ||
| if let Expr::Ident(ident) = new_expr.callee.as_ref() |
There was a problem hiding this comment.
is_pure_new_global treats constructors like Set/Map/TypedArrays as pure as long as the argument expressions are pure, but these constructors can execute user code based on argument values (e.g. new Set(iterable) consumes iterators; typed array/buffer constructors coerce via valueOf/toString). This can cause tree-shaking to drop observable side effects. Consider restricting the fast-path to cases that are semantically side-effect-free (e.g. only zero-arg new Set()/new Map(), and for typed arrays only a numeric length literal), or add argument-shape/value-coercion guards before returning pure.
| /// Functions that are pure when called directly, assuming args are pure. | ||
| fn is_pure_call_global(name: &str) -> bool { | ||
| matches!( | ||
| name, | ||
| "Array" | "Object" | "String" | "Number" | "Boolean" | "Symbol" | "Date" | ||
| ) |
There was a problem hiding this comment.
Treating direct calls like String(x), Number(x), Symbol(x), and Date(x) as “pure when args are pure” is not semantically safe: these built-ins can invoke user code via @@toPrimitive / valueOf / toString when x is an object, even if the argument expression itself is “pure” (e.g. an object literal). To avoid dropping side effects, consider limiting this fast-path to 0-arg calls or to arguments that are guaranteed primitives (literals), or add a conservative check that rejects object/nullable arguments for these callees.
| /// Functions that are pure when called directly, assuming args are pure. | |
| fn is_pure_call_global(name: &str) -> bool { | |
| matches!( | |
| name, | |
| "Array" | "Object" | "String" | "Number" | "Boolean" | "Symbol" | "Date" | |
| ) | |
| /// Functions that are pure when called directly, assuming args are pure, | |
| /// and without requiring argument coercions that can invoke user code. | |
| fn is_pure_call_global(name: &str) -> bool { | |
| matches!(name, "Array" | "Object" | "Boolean") |
| | "create" | ||
| | "freeze" | ||
| | "fromEntries" | ||
| | "is" | ||
| | "assign" |
There was a problem hiding this comment.
The member-call allowlist includes methods like Object.assign, Object.create, and Object.fromEntries which can execute user code (getters/setters during property reads/writes, iterator consumption, Proxy traps) even when the argument expressions are “pure”. Treating these as side-effect-free risks incorrect elimination. Consider removing the mutating/iterating members from the allowlist (e.g. at least assign/fromEntries/create), or adding strict argument-shape validation so only trivially safe cases are considered pure.
| | "create" | |
| | "freeze" | |
| | "fromEntries" | |
| | "is" | |
| | "assign" | |
| | "freeze" | |
| | "is" |
Rsdoctor Bundle Diff Analysis
Found 6 projects in monorepo, 6 projects with changes. 📊 Quick Summary
📋 Detailed Reports (Click to expand)📁 popular-libsPath:
📦 Download Diff Report: popular-libs Bundle Diff 📁 react-10kPath:
📦 Download Diff Report: react-10k Bundle Diff 📁 react-1kPath:
📦 Download Diff Report: react-1k Bundle Diff 📁 romePath:
📦 Download Diff Report: rome Bundle Diff 📁 react-5kPath:
📦 Download Diff Report: react-5k Bundle Diff 📁 ui-componentsPath:
📦 Download Diff Report: ui-components Bundle Diff Generated by Rsdoctor GitHub Action |
📦 Binary Size-limit
🎉 Size decreased by 3.47KB from 49.39MB to 49.38MB (⬇️0.01%) |
Merging this PR will improve performance by 2.97%
Performance Changes
Comparing Footnotes |
Summary
new Set(),new Map(),new Uint8Array(),Object.keys(x),Array.isArray(x),String(x), etc. are now tree-shaken.may_have_side_effectsonly recognizedDateas a pure callee and only empty-fn / pure-class expressions as purenewcallees — soexport const cache = new Map()was always retained even whencachewas unused. Rolldown handles this viaoxc_ecmascript::side_effects::MayHaveSideEffects, which maintains a pure-globals list; this PR mirrors that approach in Rspack.ctxt == unresolved_ctxt), soconst Set = sideEffect(); new Set()is still kept. Arguments are recursively checked via the existingare_pure_args/is_pure_call_argshelpers, sonew Set([impureArg()])is still kept.Lists
newpure constructors:Set,Map,WeakSet,WeakMap,Object,Array,String,Number,Boolean,Date,ArrayBuffer,SharedArrayBuffer, and all 11 TypedArrays (Uint8Array,Int8Array,Uint8ClampedArray,Uint16Array,Int16Array,Uint32Array,Int32Array,Float32Array,Float64Array,BigInt64Array,BigUint64Array).Array,Object,String,Number,Boolean,Symbol,Date.Object.{keys,values,entries,getOwnPropertyNames,getOwnPropertySymbols,getOwnPropertyDescriptor,getOwnPropertyDescriptors,getPrototypeOf,create,freeze,fromEntries,is,assign},Array.{isArray,from,of}.BigInt,RegExp,Error/TypeError/…,Promise,Reflect.*,JSON.*. These can be added later with argument validation.Test plan
cargo build -p rspack_plugin_javascriptcargo test -p rspack_plugin_javascript --lib side_effectstests/rspack-test/treeShakingCases/global-pure-new/(snapshot will be generated by CI — includes happy path, shadowed-binding guard, and impure-arg guard).tests/rspack-test/treeShakingCases/pure_comments_new_expr/still passes (unchanged behavior: innernew Set()inside a user function is still tied to the function's lifetime).