Skip to content

[Feature Request]: Let plugins opt a module out of preserveModules chunking (inline it into importers) #9703

Description

@rexxars

What problem does this feature solve?

I ran into this as a consumer of rolldown-plugin-dts (via tsdown) and started working on a fix in the plugin, but the clean fix would probably better be solved in rolldown, so I'm raising it here.

The plugin produces .d.ts output by injecting a second, parallel module graph (the declarations) into the same rolldown build as the JS. The two graphs share output settings, including preserveModules, but they tree-shake independently. That independence creates a module shape that has no good chunking outcome, and there's no way to tell rolldown how to handle it (let me know if I'm wrong).

A type-only source module (export interface Foo {}, re-exported elsewhere) has no runtime code, so it gets dropped from the JS graph and produces no JS chunk. The declaration graph still uses it, so under preserveModules rolldown emits a standalone chunk for it. The importing chunk then references it with a runtime extension:

// index.d.ts
import { Foo } from "./types.js";   // types.js is never emitted

tsc tolerates the dangling reference (it reads the sibling types.d.ts), but deno check and nodenext consumers fail with TS2307: Cannot find module './types.js'.

The end-user experience I'm trying to deliver: a library author runs tsdown with unbundle: true (or any preserveModules dts build), publishes, and the package type-checks cleanly under Deno and nodenext, not just tsc. Today it silently produces a broken declaration graph for that audience.

The outcome I want is the one rolldown already produces when bundling a single entry: inline the module into its importers so no cross-module specifier exists. preserveModules is what forces the split, and there's no way to say "preserve everything except this module." The workarounds don't hold up:

  • An empty stub types.js doesn't help. Deno reads the empty module and reports TS2305: no exported member 'Foo', so the specifier genuinely has to go away, not just resolve.
  • manualChunks / advancedChunks group modules into named chunks; they don't collapse a module into its importers, and I couldn't get them to influence output under preserveModules at all.

So plugins are left re-implementing inlining by hand at generateBundle: walk the imports, pull in every declaration the importer needs (including ones those declarations depend on), rename to avoid clashes, drop the chunk. That duplicates the symbol resolution rolldown's linker already does correctly in bundled mode, and it only papers over one shape of the problem.

Likely related: rolldown/tsdown#256.

What does the proposed API look like?

A way for a plugin to mark a module as inline-only, so rolldown's linker folds it into each importer instead of emitting a chunk for it under preserveModules. Either of these would solve it.

Option 1: an explicit per-module hint from resolveId / load, alongside the existing moduleSideEffects field:

{
  name: 'inline-type-only',
  resolveId(id, importer) {
    if (isTypeOnly(id)) {
      return { id, preserveModule: false } // never its own chunk, even under preserveModules
    }
  },
}

or set later via module meta, mirroring this.getModuleInfo:

load(id) {
  if (isTypeOnly(id)) {
    this.setModuleMeta?.(id, { preserveModule: false })
  }
}

Either way, a module flagged preserveModule: false would be inlined into its importers by the linker (the same path used for bundled output), so the emitted chunk and any cross-module specifier to it disappear.

Option 2: native behavior, no API surface. Under preserveModules, don't emit a chunk for a module that has no code left after tree-shaking, and inline whatever its dependents still reference. This covers the case above without a plugin opt-in, since the type-only module produces no JS at all.

I'd lean toward Option 1, since it gives plugins explicit control rather than relying on emptiness as an implicit signal, but either would let the plugin drop its hand-rolled inliner and keep the declaration output consistent with the JS.

Metadata

Metadata

Assignees

No one assigned

    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