Skip to content

feat(minifier): let the caller declare which import sources are side-effect-free, so fully-unused imports can be removed #24505

Description

@IWANABETHATGUY

Follow-up to rolldown/rolldown#10226. @Dunqing said this is doable in oxc and asked for a feature request here.

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:

import path from "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 loading node: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:

  1. 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.

  2. Dead code that only constant folding can prove dead. From @sapphi-red's repro:

    import path from "node:path";
    
    let foo = "foo".length.toString() != 3;
    if (foo) {
      console.log("foo", path);
    }

    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):

pub struct TreeShakeOptions {
    // ...existing fields...

    /// Which import sources may have side effects when loaded.
    /// Default: `All`, which is exactly today's behavior.
    pub module_side_effects: ModuleSideEffects,
}

pub enum ModuleSideEffects {
    /// 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:

  1. 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).
  2. If the user 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]: banner code cannot be tree-shaken rolldown/rolldown#10226.
  3. 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.

Metadata

Metadata

Assignees

No one assigned

    Labels

    Fields

    Priority

    None yet

    Start date

    None yet

    Target date

    None yet

    Effort

    None yet

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions