From d45ad7bf4f5807333f0b50c045a592a4c409d931 Mon Sep 17 00:00:00 2001 From: Michel EDIGHOFFER <2922851+edimitchel@users.noreply.github.com> Date: Fri, 16 Oct 2026 11:50:53 +0200 Subject: [PATCH 1/4] feat: delete stale outputs after emit Watch rebuilds and a single build overwrite files in place, then remove only the clean targets that were not emitted again. Several one-shot builds still wipe first so they can share one output directory. --- AGENTS.md | 2 +- docs/options/cleaning.md | 10 +- docs/zh-CN/options/cleaning.md | 10 +- skills/tsdown/references/option-cleaning.md | 46 +++------ src/build.ts | 22 ++-- src/config/types.ts | 8 +- src/features/clean.ts | 108 +++++++++++++++++--- tests/clean.test.ts | 102 +++++++++++++++++- 8 files changed, 242 insertions(+), 66 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index b21b66fe7..42c0b336b 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -87,7 +87,7 @@ CLI (src/run.ts → src/cli.ts) → buildWithConfigs() → buildSingle() for each config → Hook: build:prepare - → cleanOutDir() + → cleanOutDir() only when several one-shot builds run together; otherwise cleanPreservingOutputs() after emit → getBuildOptions() → constructs Rolldown config → Hook: build:before → rolldownBuild() / rolldownWatch() diff --git a/docs/options/cleaning.md b/docs/options/cleaning.md index ebee8103e..ca637b4c2 100644 --- a/docs/options/cleaning.md +++ b/docs/options/cleaning.md @@ -1,12 +1,16 @@ # Cleaning -By default, `tsdown` will **clean the output directory** (`outDir`) before each build. This ensures that any files from previous builds are removed, preventing outdated or unused files from remaining in your output. +By default, `tsdown` removes **stale files** from the output directory (`outDir`). Files that the build emits again are overwritten in place. Files left over from a previous build, and nothing else that the new build produced, are deleted only after the new output is on disk. -If you want to disable this behavior and keep existing files in the output directory, you can use the `--no-clean` option: +Watch mode uses the same rule on every successful rebuild. A tool that reads `outDir` while `tsdown --watch` is running (Vite, Nest, `tsc --watch`) keeps seeing the previous files until each one is replaced. The directory is not emptied first. + +A one-shot build of a single configuration works the same way. When several one-shot configurations are built together, their clean targets are wiped **before** emit, so those builds can write into one shared directory without deleting each other's output afterwards. + +Disable cleaning entirely with `--no-clean`: ```bash tsdown --no-clean ``` > [!NOTE] -> By default, all files in the output directory will be removed before the build process begins. Make sure this behavior aligns with your project requirements to avoid accidentally deleting important files. +> `clean: true` still deletes every file under the clean target that this build did not emit. Do not point it at a directory that contains files you need to keep. diff --git a/docs/zh-CN/options/cleaning.md b/docs/zh-CN/options/cleaning.md index ba17f7e81..0bb5ffb06 100644 --- a/docs/zh-CN/options/cleaning.md +++ b/docs/zh-CN/options/cleaning.md @@ -1,12 +1,16 @@ # 清理 -默认情况下,`tsdown` 会在每次构建之前**清理输出目录**(`outDir`)。这可以确保移除之前构建中生成的文件,防止过时或未使用的文件留在输出目录中。 +默认情况下,`tsdown` 会移除输出目录(`outDir`)中的**过期文件**。本次构建再次生成的文件会被原地覆盖;上次构建留下、而这次没有再生成的文件,只会在新产物写入磁盘之后被删除。 -如果您希望禁用此行为并保留输出目录中的现有文件,可以使用 `--no-clean` 选项: +监听模式在每次成功的重新构建后使用同一规则。在 `tsdown --watch` 运行时读取 `outDir` 的工具(Vite、Nest、`tsc --watch`)会一直看到上一份文件,直到该文件被替换。目录不会先被清空。 + +单个配置的一次性构建也是如此。多个一次性配置一起构建时,会在写出之前清空它们的清理目标,这样它们可以写入同一个目录,而不会在结束后互相删除产物。 + +用 `--no-clean` 可以完全关闭清理: ```bash tsdown --no-clean ``` > [!NOTE] -> 默认情况下,输出目录中的所有文件将在构建过程开始之前被删除。请确保此行为符合您的项目需求,以避免意外删除重要文件。 +> `clean: true` 仍会删除清理目标中本次构建没有生成的全部文件。不要把它指向还需要保留其他文件的目录。 diff --git a/skills/tsdown/references/option-cleaning.md b/skills/tsdown/references/option-cleaning.md index 9afa0f806..a81c5928c 100644 --- a/skills/tsdown/references/option-cleaning.md +++ b/skills/tsdown/references/option-cleaning.md @@ -1,10 +1,10 @@ # Output Directory Cleaning -Control how the output directory is cleaned before builds. +Control how stale output files are removed. ## Overview -By default, tsdown **cleans the output directory** before each build to remove stale files from previous builds. +By default, tsdown **overwrites emitted files and then deletes only stale ones**. The output directory is not emptied before a single build or a watch rebuild. Several one-shot builds that run together still wipe their clean targets first so they can share one directory. ## Basic Usage @@ -31,15 +31,16 @@ export default defineConfig({ ### With Cleaning (Default) -Before each build: -1. All files in `outDir` are removed -2. Fresh build starts with empty directory -3. Only current build outputs remain +For a single build and for watch rebuilds: +1. Emitted files are overwritten in place +2. After the write succeeds, files under the clean target that were not emitted are removed +3. The output directory itself stays in place the whole time + +Several one-shot builds started together still wipe their clean targets before emitting. **Benefits:** -- No stale files -- Predictable output -- Clean slate each build +- No stale files once the build finishes +- Watchers of `outDir` keep resolving the previous output during the rebuild ### Without Cleaning @@ -158,32 +159,9 @@ export default defineConfig({ ## Watch Mode Behavior -In watch mode, cleaning behavior is important: - -### Clean on First Build Only - -```ts -export default defineConfig((options) => ({ - entry: ['src/index.ts'], - watch: options.watch, - clean: !options.watch, // Only clean initial build -})) -``` - -**Result:** -- First build: Clean -- Subsequent rebuilds: Incremental - -### Always Clean - -```ts -export default defineConfig({ - watch: true, - clean: true, // Clean every rebuild -}) -``` +With `clean: true` (the default), a watch rebuild overwrites the files it emits and, after that write succeeds, deletes outputs from the previous build that were not emitted again. Consumers watching `outDir` do not see the directory disappear between rebuilds. -**Trade-off:** Slower rebuilds, but always fresh output. +`clean: false` keeps every previous file, including hashed chunks that are no longer part of the build. ## Tips diff --git a/src/build.ts b/src/build.ts index d901c823e..48095821e 100644 --- a/src/build.ts +++ b/src/build.ts @@ -11,7 +11,7 @@ import { type ResolvedConfig, } from './config/index.ts' import { warnLegacyCJS } from './features/cjs.ts' -import { cleanChunks, cleanOutDir } from './features/clean.ts' +import { cleanOutDir, cleanPreservingOutputs } from './features/clean.ts' import { copy } from './features/copy.ts' import { startDevtoolsUI } from './features/devtools.ts' import { isGlobEntry, toObjectEntry } from './features/entry.ts' @@ -85,6 +85,10 @@ export async function buildWithConfigs( rebuild: () => Promise, ): Promise { const hasWatchConfig = configs.some((config) => config.watch) + // Several one-shot builds wipe first, then write into the same tree. + // A single build and watch rebuilds emit first and delete only stale files, + // so a consumer watching `outDir` never sees it emptied. + const cleanBeforeEmit = configs.filter((config) => !config.watch).length > 1 let cleanPromise: Promise | undefined const clean = () => { if (cleanPromise) return cleanPromise @@ -132,6 +136,7 @@ export async function buildWithConfigs( configDeps, isDualFormat, clean, + cleanBeforeEmit, () => { if (restarted) return restart().catch((error) => globalLogger.error(error)) @@ -172,6 +177,7 @@ async function buildSingle( configDeps: Set, isDualFormat: boolean, clean: () => Promise, + cleanBeforeEmit: boolean, restart: () => void, done: (bundle: TsdownBundle) => Promise, ): Promise { @@ -183,7 +189,9 @@ async function buildSingle( const startTime = performance.now() await hooks.callHook('build:prepare', context) - await clean() + if (cleanBeforeEmit) { + await clean() + } // output rolldown config for debugging const debugRolldownConfigDir = await getDebugRolldownDir() @@ -267,11 +275,6 @@ async function buildSingle( switch (event.code) { case 'START': { debouncedPostBuild.cancel() - - if (config.clean.length) { - await cleanChunks(config.outDir, chunks) - } - chunks.length = 0 hasError = false ready.resolve() @@ -354,6 +357,11 @@ async function buildSingle( } async function postBuild() { + // Snapshot before the first await: the next watch cycle clears `chunks`. + const emitted = chunks.slice() + if (!cleanBeforeEmit) { + await cleanPreservingOutputs(config, emitted) + } await copy(config) await buildExe(config, chunks) diff --git a/src/config/types.ts b/src/config/types.ts index 697e3791b..36140d26c 100644 --- a/src/config/types.ts +++ b/src/config/types.ts @@ -391,9 +391,13 @@ export interface UserConfig { */ sourcemap?: Sourcemap /** - * Clean directories before build. + * Remove stale build output. + * + * Defaults to the output directory. A single build and watch rebuilds + * overwrite emitted files, then delete only the files that were not + * emitted again. Several one-shot builds still wipe their clean targets + * before emitting, so they can share one output directory. * - * Default to output directory. * @default true */ clean?: boolean | string[] diff --git a/src/features/clean.ts b/src/features/clean.ts index 5ab936b03..3bfe9d749 100644 --- a/src/features/clean.ts +++ b/src/features/clean.ts @@ -4,26 +4,25 @@ import { glob } from 'tinyglobby' import { fsRemove } from '../utils/fs.ts' import { slash } from '../utils/general.ts' import { globalLogger } from '../utils/logger.ts' +import { resolveCopyEntries } from './copy.ts' import type { ResolvedConfig, UserConfig } from '../config/index.ts' -import type { OutputAsset, OutputChunk } from 'rolldown' +import type { RolldownChunk } from '../utils/chunks.ts' const debug = createDebug('tsdown:clean') const RE_LAST_SLASH = /[/\\]$/ +/** + * Directories that are wiped before emit when several one-shot builds share + * the clean step. A single build, and every watch rebuild, uses + * {@link cleanPreservingOutputs} instead so consumers never see an empty + * output directory. + */ export async function cleanOutDir(configs: ResolvedConfig[]): Promise { const removes = new Set() for (const config of configs) { - if (config.devtools && (config.devtools.clean ?? true)) { - config.clean.push('node_modules/.rolldown') - } - - if (config.exe) { - const exeOutDir = path.resolve(config.cwd, config.exe.outDir || 'build') - config.clean.push(exeOutDir) - } - + ensureExtraCleanTargets(config) if (!config.clean.length) continue const files = await glob(config.clean, { cwd: config.cwd, @@ -32,11 +31,11 @@ export async function cleanOutDir(configs: ResolvedConfig[]): Promise { dot: true, }) - const normalizedOutDir = config.outDir.replace(RE_LAST_SLASH, '') + const outDir = normalizePath(config.outDir) for (const file of files) { - const normalizedFile = file.replace(RE_LAST_SLASH, '') - if (normalizedFile !== normalizedOutDir) { - removes.add(file) + const normalizedFile = normalizePath(file) + if (normalizedFile !== outDir) { + removes.add(normalizedFile) } } } @@ -52,6 +51,87 @@ export async function cleanOutDir(configs: ResolvedConfig[]): Promise { debug('Removed %d files', removes.size) } +/** + * Delete clean targets that this build did not emit. + * + * Emitted files are left in place (Rolldown overwrites them). Anything matched + * by `clean` that is not part of the new output is removed only after that + * output is on disk. + */ +export async function cleanPreservingOutputs( + config: ResolvedConfig, + chunks: readonly RolldownChunk[], +): Promise { + ensureExtraCleanTargets(config) + if (!config.clean.length) return + + const preservedFiles = new Set( + chunks.map((chunk) => + normalizePath( + path.resolve(chunk.outDir || config.outDir, chunk.fileName), + ), + ), + ) + if (config.copy) { + const copied = await resolveCopyEntries(config) + for (const entry of copied) preservedFiles.add(normalizePath(entry.to)) + } + + const preservedRoots = [normalizePath(config.outDir)] + const matched = await glob(config.clean, { + cwd: config.cwd, + absolute: true, + onlyFiles: false, + dot: true, + }) + const stale = matched + .map((file) => normalizePath(file)) + .filter((file) => !isPreserved(file, preservedFiles, preservedRoots)) + // Deepest paths first so a directory is removed after its stale children. + .toSorted((a, b) => b.length - a.length) + + if (!stale.length) return + + globalLogger.info(`Cleaning ${stale.length} files`) + for (const file of stale) { + debug('Removing', file) + await fsRemove(file) + } + debug('Removed %d files', stale.length) +} + +function ensureExtraCleanTargets(config: ResolvedConfig): void { + if (config.devtools && (config.devtools.clean ?? true)) { + const target = 'node_modules/.rolldown' + if (!config.clean.includes(target)) config.clean.push(target) + } + + if (!config.exe) return + const exeOutDir = path.resolve(config.cwd, config.exe.outDir || 'build') + if (!config.clean.includes(exeOutDir)) config.clean.push(exeOutDir) +} + +function normalizePath(file: string): string { + return path.resolve(file).replace(RE_LAST_SLASH, '') +} + +function isPreserved( + candidate: string, + files: Set, + roots: readonly string[], +): boolean { + if (files.has(candidate)) return true + for (const root of roots) { + if (candidate === root || root.startsWith(`${candidate}${path.sep}`)) { + return true + } + } + for (const file of files) { + if (file.startsWith(`${candidate}${path.sep}`)) return true + } + return false +} + export function resolveClean( clean: UserConfig['clean'], outDir: string, diff --git a/tests/clean.test.ts b/tests/clean.test.ts index 2b25cf6b8..59645b5fa 100644 --- a/tests/clean.test.ts +++ b/tests/clean.test.ts @@ -1,8 +1,9 @@ -import { mkdir, writeFile } from 'node:fs/promises' +import { mkdir, readFile, writeFile } from 'node:fs/promises' import path from 'node:path' import { describe, expect, test } from 'vitest' +import { build } from '../src/index.ts' import { fsExists } from '../src/utils/fs.ts' -import { getTestDir, testBuild } from './utils.ts' +import { getTestDir, testBuild, writeFixtures } from './utils.ts' describe('clean', () => { test('should clean dist directory by default', async (context) => { @@ -251,4 +252,101 @@ describe('clean', () => { expect(logExists).toBe(false) expect(keepExists).toBe(true) // This file should be preserved }) + + test('removes stale files after emit', async (context) => { + const testDir = getTestDir(context.task) + const distPath = path.join(testDir, 'dist') + const stalePath = path.join(distPath, 'old-file.js') + const outputPath = path.join(distPath, 'index.mjs') + await mkdir(distPath, { recursive: true }) + await writeFile(stalePath, 'old content') + + let staleExistedWhileWriting = false + let outputExistedWhileWriting = false + + await testBuild({ + context, + files: { + 'index.ts': 'export const hello = "world"', + }, + snapshot: false, + options: { + clean: true, + plugins: [ + { + name: 'probe-clean-after-emit', + async writeBundle() { + staleExistedWhileWriting = await fsExists(stalePath) + outputExistedWhileWriting = await fsExists(outputPath) + }, + }, + ], + }, + }) + + expect(staleExistedWhileWriting).toBe(true) + expect(outputExistedWhileWriting).toBe(true) + expect(await fsExists(stalePath)).toBe(false) + expect(await fsExists(outputPath)).toBe(true) + }) + + test('watch rebuild keeps the previous output until it is replaced', async (context) => { + const { testDir } = await writeFixtures(context, { + 'index.ts': 'export const hello = "world"', + }) + const distPath = path.join(testDir, 'dist') + const stalePath = path.join(distPath, 'obsolete.js') + const outputPath = path.join(distPath, 'index.mjs') + await mkdir(distPath, { recursive: true }) + await writeFile(stalePath, 'obsolete') + + let rebuilds = 0 + let outputExistedAtRebuildStart = false + const { promise: rebuilt, resolve: releaseRebuild } = + Promise.withResolvers() + + const handle = await build({ + cwd: testDir, + config: false, + entry: 'index.ts', + outDir: 'dist', + dts: false, + tsconfig: false, + failOnWarn: false, + logLevel: 'silent', + watch: true, + clean: true, + plugins: [ + { + name: 'probe-watch-clean', + async buildStart() { + rebuilds += 1 + if (rebuilds === 1) return + outputExistedAtRebuildStart = await fsExists(outputPath) + }, + }, + ], + async onSuccess() { + if (rebuilds > 1) { + releaseRebuild() + return + } + expect(await fsExists(stalePath)).toBe(false) + expect(await fsExists(outputPath)).toBe(true) + await writeFile( + path.join(testDir, 'index.ts'), + 'export const hello = "changed"', + ) + }, + }) + + try { + await rebuilt + expect(outputExistedAtRebuildStart).toBe(true) + expect(await fsExists(stalePath)).toBe(false) + expect(await readFile(outputPath, 'utf8')).toContain('changed') + } finally { + await handle.watch.close() + } + }) }) From 11c7710069d0762814076a2e0a818e03ff12294c Mon Sep 17 00:00:00 2001 From: Michel EDIGHOFFER <2922851+edimitchel@users.noreply.github.com> Date: Fri, 16 Oct 2026 11:54:28 +0200 Subject: [PATCH 2/4] fix: drop unused cleanChunks helper It still named OutputChunk and OutputAsset after their import was removed. --- src/features/clean.ts | 13 ------------- 1 file changed, 13 deletions(-) diff --git a/src/features/clean.ts b/src/features/clean.ts index 3bfe9d749..e04b01962 100644 --- a/src/features/clean.ts +++ b/src/features/clean.ts @@ -151,16 +151,3 @@ export function resolveClean( return clean } - -export async function cleanChunks( - outDir: string, - chunks: Array, -): Promise { - await Promise.all( - chunks.map(async (chunk) => { - const filePath = path.resolve(outDir, chunk.fileName) - debug('Removing chunk file', filePath) - await fsRemove(filePath) - }), - ) -} From 4f06164e8dfee66b04603c99e947390dc737502a Mon Sep 17 00:00:00 2001 From: Michel EDIGHOFFER <2922851+edimitchel@users.noreply.github.com> Date: Fri, 16 Oct 2026 15:59:40 +0200 Subject: [PATCH 3/4] fix: clean the union of builds that share an outDir ESM and CJS each finished by deleting every file the other had just emitted, including dist/index.d.mts. A clean now waits until every in-flight build for that directory has recorded its outputs, then removes only files none of them emitted again. --- AGENTS.md | 2 +- docs/options/cleaning.md | 2 +- docs/zh-CN/options/cleaning.md | 2 +- src/build.ts | 31 +++------ src/config/types.ts | 8 +-- src/features/clean.ts | 115 ++++++++++++++++++++------------- tests/clean.test.ts | 36 +++++++++-- 7 files changed, 118 insertions(+), 78 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 42c0b336b..32aabd592 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -87,7 +87,7 @@ CLI (src/run.ts → src/cli.ts) → buildWithConfigs() → buildSingle() for each config → Hook: build:prepare - → cleanOutDir() only when several one-shot builds run together; otherwise cleanPreservingOutputs() after emit + → finishOutput() after emit, cleaning the union of every build that shares the outDir → getBuildOptions() → constructs Rolldown config → Hook: build:before → rolldownBuild() / rolldownWatch() diff --git a/docs/options/cleaning.md b/docs/options/cleaning.md index ca637b4c2..576a5e2a9 100644 --- a/docs/options/cleaning.md +++ b/docs/options/cleaning.md @@ -4,7 +4,7 @@ By default, `tsdown` removes **stale files** from the output directory (`outDir` Watch mode uses the same rule on every successful rebuild. A tool that reads `outDir` while `tsdown --watch` is running (Vite, Nest, `tsc --watch`) keeps seeing the previous files until each one is replaced. The directory is not emptied first. -A one-shot build of a single configuration works the same way. When several one-shot configurations are built together, their clean targets are wiped **before** emit, so those builds can write into one shared directory without deleting each other's output afterwards. +Builds that share an output directory, including the ESM and CJS formats of one package, are cleaned together. Each format keeps the files it just emitted. Only files that none of them emitted again are removed, and only after the last of those builds has finished. Disable cleaning entirely with `--no-clean`: diff --git a/docs/zh-CN/options/cleaning.md b/docs/zh-CN/options/cleaning.md index 0bb5ffb06..3eecb8af6 100644 --- a/docs/zh-CN/options/cleaning.md +++ b/docs/zh-CN/options/cleaning.md @@ -4,7 +4,7 @@ 监听模式在每次成功的重新构建后使用同一规则。在 `tsdown --watch` 运行时读取 `outDir` 的工具(Vite、Nest、`tsc --watch`)会一直看到上一份文件,直到该文件被替换。目录不会先被清空。 -单个配置的一次性构建也是如此。多个一次性配置一起构建时,会在写出之前清空它们的清理目标,这样它们可以写入同一个目录,而不会在结束后互相删除产物。 +写入同一输出目录的构建(包括同一个包的 ESM 和 CJS)会一起清理。每个格式保留自己刚写出的文件。只有当它们都不再生成的文件,才会在最后一个构建结束后被删除。 用 `--no-clean` 可以完全关闭清理: diff --git a/src/build.ts b/src/build.ts index 48095821e..e6d0704d8 100644 --- a/src/build.ts +++ b/src/build.ts @@ -11,7 +11,7 @@ import { type ResolvedConfig, } from './config/index.ts' import { warnLegacyCJS } from './features/cjs.ts' -import { cleanOutDir, cleanPreservingOutputs } from './features/clean.ts' +import { finishOutput, markBuilding } from './features/clean.ts' import { copy } from './features/copy.ts' import { startDevtoolsUI } from './features/devtools.ts' import { isGlobEntry, toObjectEntry } from './features/entry.ts' @@ -85,15 +85,9 @@ export async function buildWithConfigs( rebuild: () => Promise, ): Promise { const hasWatchConfig = configs.some((config) => config.watch) - // Several one-shot builds wipe first, then write into the same tree. - // A single build and watch rebuilds emit first and delete only stale files, - // so a consumer watching `outDir` never sees it emptied. - const cleanBeforeEmit = configs.filter((config) => !config.watch).length > 1 - let cleanPromise: Promise | undefined - const clean = () => { - if (cleanPromise) return cleanPromise - return (cleanPromise = cleanOutDir(configs)) - } + // Every format is in flight until it settles, so a clean waits for the + // others that share its outDir instead of deleting their files. + for (const config of configs) markBuilding(config) const disposeCbs: Array<() => void | PromiseLike> = [] let restarted = false @@ -135,8 +129,6 @@ export async function buildWithConfigs( options, configDeps, isDualFormat, - clean, - cleanBeforeEmit, () => { if (restarted) return restart().catch((error) => globalLogger.error(error)) @@ -176,8 +168,6 @@ async function buildSingle( config: ResolvedConfig, configDeps: Set, isDualFormat: boolean, - clean: () => Promise, - cleanBeforeEmit: boolean, restart: () => void, done: (bundle: TsdownBundle) => Promise, ): Promise { @@ -189,10 +179,6 @@ async function buildSingle( const startTime = performance.now() await hooks.callHook('build:prepare', context) - if (cleanBeforeEmit) { - await clean() - } - // output rolldown config for debugging const debugRolldownConfigDir = await getDebugRolldownDir() @@ -275,6 +261,7 @@ async function buildSingle( switch (event.code) { case 'START': { debouncedPostBuild.cancel() + markBuilding(config) chunks.length = 0 hasError = false ready.resolve() @@ -282,7 +269,9 @@ async function buildSingle( } case 'END': { - if (!hasError) { + if (hasError) { + finishOutput(config).catch((error) => logger.error(error)) + } else { debouncedPostBuild() } break @@ -359,9 +348,7 @@ async function buildSingle( async function postBuild() { // Snapshot before the first await: the next watch cycle clears `chunks`. const emitted = chunks.slice() - if (!cleanBeforeEmit) { - await cleanPreservingOutputs(config, emitted) - } + await finishOutput(config, emitted) await copy(config) await buildExe(config, chunks) diff --git a/src/config/types.ts b/src/config/types.ts index 36140d26c..7f589b353 100644 --- a/src/config/types.ts +++ b/src/config/types.ts @@ -393,10 +393,10 @@ export interface UserConfig { /** * Remove stale build output. * - * Defaults to the output directory. A single build and watch rebuilds - * overwrite emitted files, then delete only the files that were not - * emitted again. Several one-shot builds still wipe their clean targets - * before emitting, so they can share one output directory. + * Defaults to the output directory. Emitted files are overwritten in + * place. After every build that shares that directory has finished, + * files that none of them emitted again are deleted. ESM and CJS outputs + * in the same directory are kept together. * * @default true */ diff --git a/src/features/clean.ts b/src/features/clean.ts index e04b01962..e992456cc 100644 --- a/src/features/clean.ts +++ b/src/features/clean.ts @@ -13,42 +13,41 @@ const debug = createDebug('tsdown:clean') const RE_LAST_SLASH = /[/\\]$/ /** - * Directories that are wiped before emit when several one-shot builds share - * the clean step. A single build, and every watch rebuild, uses - * {@link cleanPreservingOutputs} instead so consumers never see an empty - * output directory. + * Files each build last emitted, keyed by cwd + outDir + format. + * A clean keeps the union, so ESM and CJS sharing one `outDir` do not + * delete each other. */ -export async function cleanOutDir(configs: ResolvedConfig[]): Promise { - const removes = new Set() - - for (const config of configs) { - ensureExtraCleanTargets(config) - if (!config.clean.length) continue - const files = await glob(config.clean, { - cwd: config.cwd, - absolute: true, - onlyFiles: false, - dot: true, - }) +const emittedByKey = new Map }>() - const outDir = normalizePath(config.outDir) - for (const file of files) { - const normalizedFile = normalizePath(file) - if (normalizedFile !== outDir) { - removes.add(normalizedFile) - } - } +/** Builds that have been asked to run and have not settled yet. */ +const building = new Set() + +export function markBuilding(config: ResolvedConfig): void { + building.add(outputKey(config)) +} + +/** + * Record this build's outputs and clean once no other build is still + * writing into the same directory. + * + * Pass no chunks when the build failed: the previous output stays protected. + * Returns after a clean when this build was the last one in flight. + */ +export async function finishOutput( + config: ResolvedConfig, + chunks?: readonly RolldownChunk[], +): Promise { + const key = outputKey(config) + if (chunks) { + emittedByKey.set(outputKey(config), { + outDir: normalizePath(config.outDir), + files: await outputFiles(config, chunks), + }) } - if (!removes.size) return - - globalLogger.info(`Cleaning ${removes.size} files`) - await Promise.all( - [...removes].map(async (file) => { - debug('Removing', file) - await fsRemove(file) - }), - ) - debug('Removed %d files', removes.size) + building.delete(key) + + if (!config.clean.length || isOutDirBusy(config)) return + await cleanPreservingOutputs(config) } /** @@ -60,24 +59,18 @@ export async function cleanOutDir(configs: ResolvedConfig[]): Promise { */ export async function cleanPreservingOutputs( config: ResolvedConfig, - chunks: readonly RolldownChunk[], ): Promise { ensureExtraCleanTargets(config) if (!config.clean.length) return - const preservedFiles = new Set( - chunks.map((chunk) => - normalizePath( - path.resolve(chunk.outDir || config.outDir, chunk.fileName), - ), - ), - ) - if (config.copy) { - const copied = await resolveCopyEntries(config) - for (const entry of copied) preservedFiles.add(normalizePath(entry.to)) + const outDir = normalizePath(config.outDir) + const preservedFiles = new Set() + for (const emitted of emittedByKey.values()) { + if (emitted.outDir !== outDir) continue + for (const file of emitted.files) preservedFiles.add(file) } - const preservedRoots = [normalizePath(config.outDir)] + const preservedRoots = [outDir] const matched = await glob(config.clean, { cwd: config.cwd, absolute: true, @@ -100,6 +93,38 @@ export async function cleanPreservingOutputs( debug('Removed %d files', stale.length) } +function outputKey(config: ResolvedConfig): string { + return `${normalizePath(config.cwd)}\0${normalizePath(config.outDir)}\0${config.format}` +} + +function isOutDirBusy(config: ResolvedConfig): boolean { + const outDir = normalizePath(config.outDir) + const cwd = normalizePath(config.cwd) + for (const key of building) { + const [otherCwd, otherOutDir] = key.split('\0', 2) + if (otherCwd === cwd && otherOutDir === outDir) return true + } + return false +} + +async function outputFiles( + config: ResolvedConfig, + chunks: readonly RolldownChunk[], +): Promise> { + const files = new Set( + chunks.map((chunk) => + normalizePath( + path.resolve(chunk.outDir || config.outDir, chunk.fileName), + ), + ), + ) + if (!config.copy) return files + + const copied = await resolveCopyEntries(config) + for (const entry of copied) files.add(normalizePath(entry.to)) + return files +} + function ensureExtraCleanTargets(config: ResolvedConfig): void { if (config.devtools && (config.devtools.clean ?? true)) { const target = 'node_modules/.rolldown' diff --git a/tests/clean.test.ts b/tests/clean.test.ts index 59645b5fa..70b7b0921 100644 --- a/tests/clean.test.ts +++ b/tests/clean.test.ts @@ -290,6 +290,32 @@ describe('clean', () => { expect(await fsExists(outputPath)).toBe(true) }) + test('keeps every format that shares the output directory', async (context) => { + const testDir = getTestDir(context.task) + const distPath = path.join(testDir, 'dist') + await mkdir(distPath, { recursive: true }) + await writeFile(path.join(distPath, 'stale.js'), 'old') + + await testBuild({ + context, + files: { + 'index.ts': 'export const hello = "world"', + }, + snapshot: false, + options: { + clean: true, + format: ['esm', 'cjs'], + dts: true, + }, + }) + + expect(await fsExists(path.join(distPath, 'stale.js'))).toBe(false) + expect(await fsExists(path.join(distPath, 'index.mjs'))).toBe(true) + expect(await fsExists(path.join(distPath, 'index.cjs'))).toBe(true) + expect(await fsExists(path.join(distPath, 'index.d.mts'))).toBe(true) + expect(await fsExists(path.join(distPath, 'index.d.cts'))).toBe(true) + }) + test('watch rebuild keeps the previous output until it is replaced', async (context) => { const { testDir } = await writeFixtures(context, { 'index.ts': 'export const hello = "world"', @@ -300,8 +326,8 @@ describe('clean', () => { await mkdir(distPath, { recursive: true }) await writeFile(stalePath, 'obsolete') - let rebuilds = 0 let outputExistedAtRebuildStart = false + let wroteChange = false const { promise: rebuilt, resolve: releaseRebuild } = Promise.withResolvers() @@ -320,17 +346,19 @@ describe('clean', () => { { name: 'probe-watch-clean', async buildStart() { - rebuilds += 1 - if (rebuilds === 1) return + if (!wroteChange) return outputExistedAtRebuildStart = await fsExists(outputPath) }, }, ], async onSuccess() { - if (rebuilds > 1) { + const output = await readFile(outputPath, 'utf8').catch(() => '') + if (output.includes('changed')) { releaseRebuild() return } + if (wroteChange) return + wroteChange = true expect(await fsExists(stalePath)).toBe(false) expect(await fsExists(outputPath)).toBe(true) await writeFile( From a00f9fe3140ed2870584864d18815f3573583c09 Mon Sep 17 00:00:00 2001 From: Michel EDIGHOFFER <2922851+edimitchel@users.noreply.github.com> Date: Fri, 16 Oct 2026 17:41:12 +0200 Subject: [PATCH 4/4] fix: rebuild from scratch in watch mode Rolldown's incremental watch removes a shared outDir between passes, so tsdown --watch can leave dist empty. Watch the project and run a normal build instead. --- src/build.ts | 189 ++++++++++++++++----------------------- src/features/rolldown.ts | 6 -- src/features/watch.ts | 65 +++++++++++++- tests/clean.test.ts | 99 ++++++++++++++++++++ 4 files changed, 239 insertions(+), 120 deletions(-) diff --git a/src/build.ts b/src/build.ts index e6d0704d8..0f0498c17 100644 --- a/src/build.ts +++ b/src/build.ts @@ -1,10 +1,6 @@ +import path from 'node:path' import { clearRequireCache } from 'import-without-cache' -import { - build as rolldownBuild, - watch as rolldownWatch, - type BuildOptions, - type RolldownWatcher, -} from 'rolldown' +import { build as rolldownBuild, type BuildOptions } from 'rolldown' import { resolveConfig, type InlineConfig, @@ -12,7 +8,7 @@ import { } from './config/index.ts' import { warnLegacyCJS } from './features/cjs.ts' import { finishOutput, markBuilding } from './features/clean.ts' -import { copy } from './features/copy.ts' +import { copy, resolveCopyEntries } from './features/copy.ts' import { startDevtoolsUI } from './features/devtools.ts' import { isGlobEntry, toObjectEntry } from './features/entry.ts' import { buildExe } from './features/exe.ts' @@ -24,14 +20,14 @@ import { getDebugRolldownDir, } from './features/rolldown.ts' import { shortcuts } from './features/shortcuts.ts' -import { endsWithConfig } from './features/watch.ts' +import { endsWithConfig, watchForRebuild } from './features/watch.ts' import { addOutDirToChunks, type RolldownChunk, type TsdownBundle, type TsdownHandle, } from './utils/chunks.ts' -import { debounce, typeAssert } from './utils/general.ts' +import { debounce, resolveComma, toArray, typeAssert } from './utils/general.ts' import { globalLogger } from './utils/logger.ts' import { styleText } from './utils/style.ts' @@ -176,18 +172,14 @@ async function buildSingle( warnLegacyCJS(config) - const startTime = performance.now() await hooks.callHook('build:prepare', context) // output rolldown config for debugging const debugRolldownConfigDir = await getDebugRolldownDir() const chunks: RolldownChunk[] = [] - let watcher: RolldownWatcher | undefined let ab: AbortController | undefined - const debouncedPostBuild = debounce(() => { - postBuild().catch((error) => logger.error(error)) - }, 100) + let stopWatching: (() => void) | undefined let hasBuilt = false const bundle: TsdownBundle = { @@ -195,117 +187,66 @@ async function buildSingle( config, inlinedDeps: new Map(), async [Symbol.asyncDispose]() { - debouncedPostBuild.cancel() ab?.abort() - await watcher?.close() + await Promise.resolve(stopWatching?.()) }, } const configs = await initBuildOptions() - if (watch) { - watcher = rolldownWatch(configs) - await handleWatcher(watcher) - } else { - const outputs = await config.runBuild(() => rolldownBuild(configs)) - for (const { output } of outputs) { - chunks.push(...addOutDirToChunks(output, outDir)) - } - } - if (!watch) { - logger.success( - config.nameLabel, - `Build complete in ${styleText.green(`${Math.round(performance.now() - startTime)}ms`)}`, - ) - await postBuild() + async function emit(): Promise { + const emitStartedAt = performance.now() + chunks.length = 0 + markBuilding(config) + try { + const outputs = await config.runBuild(() => rolldownBuild(configs)) + chunks.length = 0 + for (const { output } of outputs) { + chunks.push(...addOutDirToChunks(output, outDir)) + } + logger.success( + config.nameLabel, + `Build complete in ${styleText.green(`${Math.round(performance.now() - emitStartedAt)}ms`)}`, + ) + await postBuild() + } catch (error) { + await finishOutput(config) + throw error + } } - return bundle - - function handleWatcher(watcher: RolldownWatcher) { - const ready = Promise.withResolvers() - const changedFile: string[] = [] - let hasError = false - - watcher.on('change', async (id, event) => { - if (event.event === 'update') { - changedFile.push(id) - // Cancel pending postBuild immediately on file change, - // before the new build cycle starts. This prevents duplicate - // onSuccess execution when rapid file changes (e.g. VS Code - // auto-save) trigger multiple build cycles. - debouncedPostBuild.cancel() - ab?.abort() - } - if (configDeps.has(id) || endsWithConfig.test(id)) { - globalLogger.info(`Reload config: ${id}, restarting...`) - restart() - } - // eslint-disable-next-line unicorn/prefer-early-return - if ( - (event.event === 'create' || event.event === 'delete') && - config.rawEntry && - isGlobEntry(config.rawEntry) - ) { - const [newEntry] = await toObjectEntry(config.rawEntry, config.cwd) - const currentKeys = Object.keys(config.entry).toSorted().join('\0') - const newKeys = Object.keys(newEntry).toSorted().join('\0') - if (currentKeys !== newKeys) { - globalLogger.info('Entry files changed, restarting...') - restart() - } + await emit() + if (watch) { + let rebuildQueue: Promise = Promise.resolve() + const rebuild = debounce((file: string, event: string) => { + rebuildQueue = rebuildQueue + .then(() => onFileChange(file, event)) + .catch((error: unknown) => logger.error(error)) + }, 100) + const extraWatch = new Set(configDeps) + if (typeof config.tsconfig === 'string') extraWatch.add(config.tsconfig) + if (config.pkg) extraWatch.add(config.pkg.packageJsonPath) + if (typeof config.watch !== 'boolean') { + for (const file of resolveComma(toArray(config.watch))) { + extraWatch.add(path.resolve(config.cwd, file)) } - }) - - watcher.on('event', async (event) => { - switch (event.code) { - case 'START': { - debouncedPostBuild.cancel() - markBuilding(config) - chunks.length = 0 - hasError = false - ready.resolve() - break - } - - case 'END': { - if (hasError) { - finishOutput(config).catch((error) => logger.error(error)) - } else { - debouncedPostBuild() - } - break - } - - case 'BUNDLE_START': { - if (changedFile.length) { - logger.clearScreen('info') - logger.info( - `Found ${styleText.bold(changedFile.join(', '))} changed, rebuilding...`, - ) - } - changedFile.length = 0 - break - } - - case 'BUNDLE_END': { - await event.result.close() - logger.success(config.nameLabel, `Rebuilt in ${event.duration}ms.`) - break - } - - case 'ERROR': { - await event.result.close() - logger.error(event.error) - hasError = true - break - } + } + if (config.copy) { + for (const entry of await resolveCopyEntries(config)) { + extraWatch.add(entry.from) } + } + const fsWatcher = watchForRebuild(config, extraWatch, (file, event) => { + rebuild(file, event) }) - - return ready.promise + stopWatching = () => { + rebuild.cancel() + fsWatcher.close() + } } + return bundle + async function initBuildOptions() { const buildOptions = await getBuildOptions( config, @@ -345,8 +286,30 @@ async function buildSingle( return configs } + async function onFileChange(file: string, event: string): Promise { + if (configDeps.has(file) || endsWithConfig.test(file)) { + globalLogger.info(`Reload config: ${file}, restarting...`) + restart() + return + } + if (event === 'rename' && config.rawEntry && isGlobEntry(config.rawEntry)) { + const [newEntry] = await toObjectEntry(config.rawEntry, config.cwd) + const currentKeys = Object.keys(config.entry).toSorted().join('\0') + const newKeys = Object.keys(newEntry).toSorted().join('\0') + if (currentKeys !== newKeys) { + globalLogger.info('Entry files changed, restarting...') + restart() + return + } + } + logger.clearScreen('info') + logger.info(`Found ${styleText.bold(file)} changed, rebuilding...`) + await emit() + } + async function postBuild() { - // Snapshot before the first await: the next watch cycle clears `chunks`. + // Snapshot before the first await. The next `emit` clears `chunks`, so + // `exe` and hooks still see this build's output. const emitted = chunks.slice() await finishOutput(config, emitted) await copy(config) diff --git a/src/features/rolldown.ts b/src/features/rolldown.ts index 40770ebf8..0c880dbe9 100644 --- a/src/features/rolldown.ts +++ b/src/features/rolldown.ts @@ -27,7 +27,6 @@ import { resolveChunkAddon, resolveChunkFilename } from './output.ts' import { ReportPlugin } from './report.ts' import { ShebangPlugin } from './shebang.ts' import { getShims } from './shims.ts' -import { WatchPlugin } from './watch.ts' import type { DtsOptions, NormalizedFormat, @@ -107,7 +106,6 @@ async function resolveInputOptions( treeshake, tsconfig, unused, - watch, } = config const loggerLevelIndex = LogLevels[logger.level] const isSuppressed = createSuppressWarnings(logger.options?.suppressWarnings) @@ -180,10 +178,6 @@ async function resolveInputOptions( plugins.push(ReportPlugin(config, cjsDts, isDualFormat)) } - if (watch) { - plugins.push(WatchPlugin(configDeps, bundle)) - } - if (!cjsDts) { plugins.push(userPlugins) } diff --git a/src/features/watch.ts b/src/features/watch.ts index 558269f9e..b7d142899 100644 --- a/src/features/watch.ts +++ b/src/features/watch.ts @@ -1,12 +1,75 @@ +import { statSync, watch, type FSWatcher } from 'node:fs' +import path from 'node:path' import { addOutDirToChunks } from '../utils/chunks.ts' import { resolveComma, toArray } from '../utils/general.ts' import { resolveCopyEntries } from './copy.ts' -import type { TsdownBundle } from '../config/types.ts' +import type { ResolvedConfig, TsdownBundle } from '../config/types.ts' import type { Plugin } from 'rolldown' +function isInsideDir(dir: string, file: string): boolean { + const relative = path.relative(dir, file) + return ( + relative === '' || + (!relative.startsWith('..') && !path.isAbsolute(relative)) + ) +} + export const endsWithConfig: RegExp = /[\\/](?:tsdown\.config.*|package\.json|tsconfig\.json)$/ +/** + * Full rebuilds on source changes. Rolldown's incremental watch drops the + * output directory when several builds share it, so `tsdown --watch` repeats + * the normal build instead. + */ +export function watchForRebuild( + config: ResolvedConfig, + extraFiles: Iterable, + onChange: (file: string, event: string) => void, +): { close: () => void } { + const ignoredDirs = [config.outDir, path.join(config.cwd, 'node_modules')] + const watchers: FSWatcher[] = [] + + const handle = (file: string, event: string) => { + if (ignoredDirs.some((dir) => isInsideDir(dir, file))) return + if (path.relative(config.cwd, file).split(path.sep).includes('.git')) return + if ( + config.ignoreWatch.some((pattern) => + typeof pattern === 'string' + ? file.includes(pattern) + : pattern.test(file), + ) + ) { + return + } + onChange(file, event) + } + + watchers.push( + watch(config.cwd, { recursive: true }, (event, name) => { + if (!name) return + handle(path.resolve(config.cwd, name), event) + }), + ) + + for (const extra of extraFiles) { + if (isInsideDir(config.cwd, extra)) continue + const isDirectory = statSync(extra).isDirectory() + watchers.push( + watch(extra, isDirectory ? { recursive: true } : {}, (event, name) => { + const file = isDirectory && name ? path.resolve(extra, name) : extra + handle(file, event) + }), + ) + } + + return { + close() { + for (const watcher of watchers) watcher.close() + }, + } +} + export function WatchPlugin( configDeps: Set, { config, chunks }: TsdownBundle, diff --git a/tests/clean.test.ts b/tests/clean.test.ts index 70b7b0921..156df813f 100644 --- a/tests/clean.test.ts +++ b/tests/clean.test.ts @@ -377,4 +377,103 @@ describe('clean', () => { await handle.watch.close() } }) + + test('watch rebuild keeps every format that shares the output directory', async (context) => { + const { testDir } = await writeFixtures(context, { + 'index.ts': 'export const hello = "world"', + }) + const distPath = path.join(testDir, 'dist') + const stalePath = path.join(distPath, 'obsolete.js') + const mjsPath = path.join(distPath, 'index.mjs') + const cjsPath = path.join(distPath, 'index.cjs') + const mtsPath = path.join(distPath, 'index.d.mts') + const ctsPath = path.join(distPath, 'index.d.cts') + await mkdir(distPath, { recursive: true }) + await writeFile(stalePath, 'obsolete') + + let wroteChange = false + let outputExistedAtRebuildStart: boolean | undefined + const { promise: rebuilt, resolve: releaseRebuild } = + Promise.withResolvers() + + async function outputsReady(): Promise { + return ( + (await fsExists(mjsPath)) && + (await fsExists(cjsPath)) && + (await fsExists(mtsPath)) && + (await fsExists(ctsPath)) + ) + } + + const handle = await build({ + cwd: testDir, + config: false, + entry: 'index.ts', + outDir: 'dist', + format: ['esm', 'cjs'], + dts: true, + tsconfig: false, + failOnWarn: false, + logLevel: 'silent', + watch: true, + clean: true, + plugins: [ + { + name: 'probe-watch-dual-format', + async buildStart() { + if (!wroteChange) return + const exists = await outputsReady() + outputExistedAtRebuildStart = + (outputExistedAtRebuildStart ?? true) && exists + }, + }, + ], + // `onSuccess` runs for the first format only. + async onSuccess() { + if (!wroteChange) { + const deadline = Date.now() + 5_000 + while (!(await outputsReady()) && Date.now() < deadline) { + await new Promise((resolve) => setTimeout(resolve, 20)) + } + expect(await fsExists(stalePath)).toBe(false) + expect(await outputsReady()).toBe(true) + wroteChange = true + await writeFile( + path.join(testDir, 'index.ts'), + 'export const hello = "changed"', + ) + return + } + + const deadline = Date.now() + 5_000 + while (Date.now() < deadline) { + const [mjs, cjs] = await Promise.all([ + readFile(mjsPath, 'utf8').catch(() => ''), + readFile(cjsPath, 'utf8').catch(() => ''), + ]) + if ( + mjs.includes('changed') && + cjs.includes('changed') && + (await outputsReady()) + ) { + releaseRebuild() + return + } + await new Promise((resolve) => setTimeout(resolve, 20)) + } + }, + }) + + try { + await rebuilt + expect(outputExistedAtRebuildStart).toBe(true) + expect(await fsExists(stalePath)).toBe(false) + expect(await fsExists(mtsPath)).toBe(true) + expect(await fsExists(ctsPath)).toBe(true) + expect(await readFile(mjsPath, 'utf8')).toContain('changed') + expect(await readFile(cjsPath, 'utf8')).toContain('changed') + } finally { + await handle.watch.close() + } + }) })