You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
When DCE figures out that no binding of an import is used, the minifier shrinks the statement as far as it can:
importpathfrom"node:path";// `path` is unused
becomes
import"node:path";
and stops there. It has to stop there: at that point the minifier is looking at a single chunk of text, and it has no way to know whether loadingnode:path has side effects. Deleting the statement could change behavior, so keeping it is the only safe move.
The thing is, the caller usually does know. A bundler has the module graph and the user's treeshake.moduleSideEffects config, so for every import source in the chunk it can answer "is it safe to drop a load of this module?" with a plain yes/no. There's currently no way to hand that answer to the minifier.
Where this bites
Two real cases from rolldown/rolldown#10226 where the bundler's own tree-shaking can't help and the minifier is the only stage in a position to act:
Injected text (banner, footer, intro, outro). It's glued onto the chunk after linking, so it never went through the module graph. If a banner contains import path from "node:path", link-time tree-shaking never sees it, and the minifier leaves import "node:path" behind even though the user configured moduleSideEffects: false.
Dead code that only constant folding can prove dead. From @sapphi-red's repro:
The bundler can't tell the branch is dead, so it keeps the import. The minifier folds the condition to false, drops the branch, and now path is unused — but again, all it can do is strand a bare import "node:path".
Proposal
Add one plain-data field to TreeShakeOptions (it already models import-related assumptions there, e.g. invalid_import_side_effects):
pubstructTreeShakeOptions{// ...existing fields.../// Which import sources may have side effects when loaded./// Default: `All`, which is exactly today's behavior.pubmodule_side_effects:ModuleSideEffects,}pubenumModuleSideEffects{/// Every import source may have side effects (default, status quo).All,/// Only these sources have side effects. Imports of anything else are/// removable once all their bindings are unused.Only(FxHashSet<String>),/// These sources are side-effect-free. Everything else keeps side effects.AllExcept(FxHashSet<String>),}
Matching is an exact string comparison against the module request as written in the source ("node:path" matches import ... from "node:path"). No resolution, no globs, no normalization — the caller is responsible for listing the exact specifier strings that appear in the code it hands over.
When a source is judged side-effect-free, the minifier may:
delete a bare import "x";
delete a whole import statement (import d from "x", import { a } from "x", import * as ns from "x") once all of its bindings are unused — the existing unused-specifier logic already makes that call, it just currently downgrades to a bare import instead of deleting
Removal takes part in the normal compress fixpoint, so cascades work: fold a branch dead → binding becomes unused → import gets deleted, all in one run.
Out of scope for a first version, though the same knowledge would apply later:
export ... from "x" — that's the module's public interface, never touched
dynamic import("x") and CommonJS require("x") — expression-level, deserve their own rules
Since the default is All, nothing changes for any existing consumer until they opt in, and the conformance snapshots shouldn't move at all.
Why this shape and not something else
Why not a callback like fn(&str) -> bool? It looks more flexible, but it would be unusable for the consumer that's asking for this: rolldown's most general moduleSideEffects form is an async JS function coming over napi, and there's no way to call that synchronously in the middle of a compress pass. A callback would also cost CompressOptions its Clone/Debug/serializability and couldn't be exposed through the oxc-minify npm package. Plain data keeps the minifier a pure function of (source_text, options), which is also what keeps its tests simple.
Why not just a boolean (module_side_effects: false)? A bundler chunk almost always contains imports that must survive no matter what the user configured: imports of sibling chunks, which carry wrapper init and other code that has to run. A boolean can't say "nothing has side effects except these two chunk files"; Only(set) can. The three variants cover the boolean cases anyway (All / Only(empty)).
Why not have the bundler inject /* @__PURE__ */-style comments? The bundler can't annotate what it never parses — banner text is the motivating case. And it would turn what is really a private bundler-to-minifier channel into an ecosystem comment convention.
How rolldown would use it
minify_chunks already clones the minifier options per chunk, so feeding per-chunk data in is natural:
For each external import rendered into a chunk, look up the side-effect verdict rolldown already computed at scan time; the specifiers that are known side-effect-free become AllExcept(set).
No new user-facing option on the rolldown side. Everything derives from the existing treeshake.moduleSideEffects, and it kicks in for the default minify: 'dce-only' mode too.
One known limitation we're fine with: when moduleSideEffects is a function, rolldown can only pre-evaluate sources it knows from the graph, so an unknown banner-only specifier stays conservative.
Happy to adjust naming or the exact shape — the part we care about is the direction: the caller hands over a declarative per-source verdict, and the minifier gets to delete import statements it can currently only shrink.
What happens today
When DCE figures out that no binding of an import is used, the minifier shrinks the statement as far as it can:
becomes
and stops there. It has to stop there: at that point the minifier is looking at a single chunk of text, and it has no way to know whether loading
node:pathhas side effects. Deleting the statement could change behavior, so keeping it is the only safe move.The thing is, the caller usually does know. A bundler has the module graph and the user's
treeshake.moduleSideEffectsconfig, so for every import source in the chunk it can answer "is it safe to drop a load of this module?" with a plain yes/no. There's currently no way to hand that answer to the minifier.Where this bites
Two real cases from rolldown/rolldown#10226 where the bundler's own tree-shaking can't help and the minifier is the only stage in a position to act:
Injected text (
banner,footer,intro,outro). It's glued onto the chunk after linking, so it never went through the module graph. If a banner containsimport path from "node:path", link-time tree-shaking never sees it, and the minifier leavesimport "node:path"behind even though the user configuredmoduleSideEffects: false.Dead code that only constant folding can prove dead. From @sapphi-red's repro:
The bundler can't tell the branch is dead, so it keeps the import. The minifier folds the condition to
false, drops the branch, and nowpathis unused — but again, all it can do is strand a bareimport "node:path".Proposal
Add one plain-data field to
TreeShakeOptions(it already models import-related assumptions there, e.g.invalid_import_side_effects):Matching is an exact string comparison against the module request as written in the source (
"node:path"matchesimport ... from "node:path"). No resolution, no globs, no normalization — the caller is responsible for listing the exact specifier strings that appear in the code it hands over.When a source is judged side-effect-free, the minifier may:
import "x";import d from "x",import { a } from "x",import * as ns from "x") once all of its bindings are unused — the existing unused-specifier logic already makes that call, it just currently downgrades to a bare import instead of deletingRemoval takes part in the normal compress fixpoint, so cascades work: fold a branch dead → binding becomes unused → import gets deleted, all in one run.
Out of scope for a first version, though the same knowledge would apply later:
export ... from "x"— that's the module's public interface, never touchedimport("x")and CommonJSrequire("x")— expression-level, deserve their own rulesSince the default is
All, nothing changes for any existing consumer until they opt in, and the conformance snapshots shouldn't move at all.Why this shape and not something else
Why not a callback like
fn(&str) -> bool? It looks more flexible, but it would be unusable for the consumer that's asking for this: rolldown's most generalmoduleSideEffectsform is an async JS function coming over napi, and there's no way to call that synchronously in the middle of a compress pass. A callback would also costCompressOptionsitsClone/Debug/serializability and couldn't be exposed through theoxc-minifynpm package. Plain data keeps the minifier a pure function of(source_text, options), which is also what keeps its tests simple.Why not just a boolean (
module_side_effects: false)? A bundler chunk almost always contains imports that must survive no matter what the user configured: imports of sibling chunks, which carry wrapper init and other code that has to run. A boolean can't say "nothing has side effects except these two chunk files";Only(set)can. The three variants cover the boolean cases anyway (All/Only(empty)).Why not have the bundler inject
/* @__PURE__ */-style comments? The bundler can't annotate what it never parses — banner text is the motivating case. And it would turn what is really a private bundler-to-minifier channel into an ecosystem comment convention.How rolldown would use it
minify_chunksalready clones the minifier options per chunk, so feeding per-chunk data in is natural:AllExcept(set).treeshake.moduleSideEffects: false, invert it:Only({the chunk's cross-chunk import specifiers}). That's what covers banner imports of modules the graph has never seen — the literal case in [Feature request]:bannercode cannot be tree-shaken rolldown/rolldown#10226.treeshake.moduleSideEffects, and it kicks in for the defaultminify: 'dce-only'mode too.One known limitation we're fine with: when
moduleSideEffectsis a function, rolldown can only pre-evaluate sources it knows from the graph, so an unknown banner-only specifier stays conservative.Happy to adjust naming or the exact shape — the part we care about is the direction: the caller hands over a declarative per-source verdict, and the minifier gets to delete import statements it can currently only shrink.