Skip to content

skipNodeModulesBundle bundles deps it can't resolve (non-node platform + dependency without an exports field) #993

Closed

Description

@adamhl8

Reproduction link or steps

https://stackblitz.com/edit/github-va9a7t9q?file=tsdown.config.ts

deps.skipNodeModulesBundle is documented as "Skip bundling all node_modules dependencies." But for any dependency published without an exports field (legacy main/types only), it gets bundled anyway whenever platform is not "node". For the dts build this causes an error when the inlined package transitively references a CJS .d.ts.

This issue requires a combination of deps.skipNodeModulesBundle: true + a non-node platform + a dependency without an exports field.

What is expected?

deps.skipNodeModulesBundle: true should externalize every bare / non-relative node_modules import, regardless of whether tsdown's resolver can resolve it and regardless of the configured platform.

What is actually happening?

Under a non-node platform, rolldown's resolver does not fall back to the legacy main/types fields, so packages without an exports field don't get resolved. skipNodeModulesBundle only externalizes when that resolve succeeds, so these deps get inlined.

Any additional comments?

The following was generated by Claude:

In DepsPlugin (tsdown/dist/format-*.mjs), externalStrategy only externalizes a node_modules import when the prior this.resolve(...) succeeded:

// resolveId handler
const resolved = await this.resolve(id, importer, { ...extraOptions, skipSelf: true })
let shouldExternal = await externalStrategy(id, importer, resolved)

// externalStrategy
async function externalStrategy(id, importer, resolved) {
  // ...
  if (skipNodeModulesBundle && resolved && (resolved.external || RE_NODE_MODULES.test(resolved.id))) {
    const resolvedDep = await resolveDepSubpath(id, resolved)
    return resolvedDep ? [true, resolvedDep] : true
  }
  // ...
  return false // resolve failed -> NOT externalized -> bundled
}

Under platform: "neutral", resolved is null for a no-exports package, so the function falls through to return false and the dep is bundled.

Workaround

deps.neverBundle feeds rolldown's native external (matched on the specifier, no resolution required), so it externalizes correctly. Note it is tested against both the raw specifier and the resolved absolute path, so internal/absolute paths must be excluded:

export default defineConfig({
  entry: ["./src/index.ts"],
  dts: true,
  platform: "neutral",
  deps: {
    skipNodeModulesBundle: true,
    neverBundle: (id) => !path.isAbsolute(id) && !id.startsWith(".") && !id.startsWith("#"),
  },
})

Activity

  1. added theissue type on Jun 29, 2026
  2. added 2 commits that reference this issue on Jul 9, 2026
    b367a20
    67dd034
  3. sxzz commented on Jul 21, 2026

    @sxzz
    Member

    skipNodeModulesBundle is deprecated now, deps.neverBundle: true can fix your case.

  4. added a commit that references this issue on Jul 21, 2026
    d30a730
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Type

    Fields

    Priority

    None yet

    Effort

    None yet

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions