Skip to content

perf(rolldown): build all DTS entries in a single rolldown call#151

Open
vitbokisch wants to merge 3 commits into
mainfrom
perf/rolldown-single-dts-pass
Open

perf(rolldown): build all DTS entries in a single rolldown call#151
vitbokisch wants to merge 3 commits into
mainfrom
perf/rolldown-single-dts-pass

Conversation

@vitbokisch

Copy link
Copy Markdown
Member

Summary

You were right to push back on the earlier negative result. Trying the single multi-entry DTS pass approach instead of parallel-Promise.all turned out to be the real win.

The `[PLUGIN_TIMINGS] rolldown-plugin-dts:generate` warning is fired per call into the plugin. Each per-entry DTS call instantiates the plugin (and its embedded TS compiler) from scratch. Building all entries in one call amortizes that setup AND lets common imports (e.g. a shared `types.ts`) emit as one `_chunks/*.d.ts` instead of inlining into every entry's stub.

Measured (apples-to-apples on the repo's own DTS fixtures)

Fixture Old (per-entry) New (single-pass) Speedup
`dts-pipeline` (3 entries, `.tsx` + `.ts` mix) 675ms 380ms 1.78×
`multi-entry-sharing` (3 entries, shared `sentinel.ts`) 551ms 348ms 1.58×

Win scales with entry count — per-call plugin instantiation is the dominant cost, now amortized to one per group.

What I tried first (negative — discarded)

Bounded-concurrency `Promise.all` over the per-entry loop. Measured:

  • 3-entry fixture: 559ms → 572ms (no change)
  • 5-entry fixture (200-type shared module): 820ms → 816ms (no change)

Per-entry duration tripled under that approach (≈655ms each vs ≈120ms sequential) because `Promise.all` over CPU-bound JS work serializes on V8's single thread. Discarded, branch deleted.

What works (this PR)

Mirrors PR #139's JS multi-entry shared-chunk architecture:

  1. Partition DTS configs by output dir.
  2. Buckets of ≥2 entries → one `rolldown()` call with `input` as an entry map and `chunkFileNames: '_chunks/[name]-[hash].d.ts'`.
  3. The "find largest `.d.ts` and rename" trick that handles the plugin's `.d.ts` (stub) + `2.d.ts` (real) pair is preserved — just batched across entries.
  4. Single-entry packages keep `buildDtsIsolated` (no churn).

Regression-locked

The integration test now asserts:

  • Build output contains the `single-pass` log marker (proves the new path is taken).
  • Build output does not contain per-entry timing lines for grouped entries (would mean silent fallback).
  • The plugin's `2.d.ts` artifacts are cleaned up — anything else means we'd ship raw plugin output.

e2e verification

  • 576 tests pass (+2 new regression assertions)
  • ✅ Existing DTS pipeline integration test still green (content correctness, isolated temp dir handling, `.tsx` resolution)
  • ✅ Existing shared-chunks integration test still green (runtime identity preserved across sub-entries — the @pyreon/head fix)
  • ✅ Typecheck + lint clean across all 10 packages
  • ✅ All 10 packages build, zero leaked `__dts_tmp` dirs*

Versioning

`@vitus-labs/tools-rolldown`: minor — semantically a perf fix, but consumers will see a new `_chunks/` dir alongside the `.d.ts` files for multi-entry packages (the shared chunk file). Additive, no API change.

🤖 Generated with Claude Code

vitbokisch and others added 3 commits June 11, 2026 19:31
Each per-entry DTS call instantiates the slow rolldown-plugin-dts (and
its embedded TS compiler) from scratch — that's the [PLUGIN_TIMINGS]
warning rolldown itself surfaces. Doing it in one call amortizes that
setup AND lets common imports emit as a single _chunks/*.d.ts instead
of being inlined into every entry's stub.

Same architecture as PR #139's JS multi-entry grouping: partition DTS
configs by output dir, run groups of >=2 entries in one rolldown call,
keep single-entry packages on the existing buildDtsIsolated path. The
"find largest .d.ts" trick that handles the plugin's <name>.d.ts stub
+ <name>NUMBER.d.ts real pair is preserved, just batched.

Measured on repo's own DTS fixtures:
  dts-pipeline       675ms -> 380ms  (1.78x)
  multi-entry-share  551ms -> 348ms  (1.58x)

Scales further with entry count — per-call plugin instantiation is
the dominant cost, now amortized.

Regression-locked: integration test asserts the build output contains
"single-pass" marker AND not the per-entry timing lines for grouped
entries — silent fallback to per-entry would fail the test.

e2e: 576 tests pass (+2 new regression locks), typecheck + lint clean,
all 10 packages build, zero leaked __dts_tmp* dirs.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The first version of the single-pass DTS path had a real bug — proven
by a strict-mode (skipLibCheck:false) typecheck on the produced lib.

When the plugin code-splits across entries, it emits:
  - <stem>.d.ts        (re-export stub)
  - <stem>NUMBER.d.ts  (real declarations)
The other entry files reference the real-decls path:
  import { X } from "./<stem>NUMBER.js"

The post-process renames <stem>NUMBER.d.ts -> <stem>.d.ts but DID NOT
rewrite the import paths in sibling entry files. Result: index.d.ts
contained `import { ... } from "./component2.js"` after `component2.d.ts`
had been renamed away. Real consumers fail with:
  TS2307: Cannot find module './component2.js'

(The first PR's tests asserted file existence + content patterns only —
they didn't typecheck the produced .d.ts strictly, so the break slipped
through. Adding that as a regression lock.)

Fix:
  - Track <oldStem -> newStem> renames during the promote step.
  - After promote, scan each entry file for `./oldStem.js` import
    specifiers and rewrite to `./newStem.js`.
  - Tighten the candidate regex (drop a loose startsWith fallback).
  - Split buildDtsGrouped into focused helpers to drop complexity.

Regression lock (new test in build.integration.test.ts):
  - Typecheck the produced lib/types/*.d.ts with skipLibCheck:false.
  - Without the fix: fails with TS2307 (verified by temporarily
    skipping repairStaleImports).
  - With the fix: passes.

e2e: 579 tests pass (+3 new), typecheck + lint clean, all 10 packages
build, zero leaked __dts_tmp* dirs.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant