diff --git a/AGENTS.md b/AGENTS.md index b21b66fe7..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() + → 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 ebee8103e..576a5e2a9 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. + +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`: ```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..3eecb8af6 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`)会一直看到上一份文件,直到该文件被替换。目录不会先被清空。 + +写入同一输出目录的构建(包括同一个包的 ESM 和 CJS)会一起清理。每个格式保留自己刚写出的文件。只有当它们都不再生成的文件,才会在最后一个构建结束后被删除。 + +用 `--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..0f0498c17 100644 --- a/src/build.ts +++ b/src/build.ts @@ -1,18 +1,14 @@ +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, type ResolvedConfig, } from './config/index.ts' import { warnLegacyCJS } from './features/cjs.ts' -import { cleanChunks, cleanOutDir } from './features/clean.ts' -import { copy } from './features/copy.ts' +import { finishOutput, markBuilding } from './features/clean.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' @@ -85,11 +81,9 @@ export async function buildWithConfigs( rebuild: () => Promise, ): Promise { const hasWatchConfig = configs.some((config) => config.watch) - 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 @@ -131,7 +125,6 @@ export async function buildWithConfigs( options, configDeps, isDualFormat, - clean, () => { if (restarted) return restart().catch((error) => globalLogger.error(error)) @@ -171,7 +164,6 @@ async function buildSingle( config: ResolvedConfig, configDeps: Set, isDualFormat: boolean, - clean: () => Promise, restart: () => void, done: (bundle: TsdownBundle) => Promise, ): Promise { @@ -180,20 +172,14 @@ async function buildSingle( warnLegacyCJS(config) - const startTime = performance.now() await hooks.callHook('build:prepare', context) - await clean() - // 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 = { @@ -201,119 +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() - - if (config.clean.length) { - await cleanChunks(config.outDir, chunks) - } - - chunks.length = 0 - hasError = false - ready.resolve() - break - } - - case 'END': { - if (!hasError) { - 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, @@ -353,7 +286,32 @@ 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 `emit` clears `chunks`, so + // `exe` and hooks still see this build's output. + const emitted = chunks.slice() + await finishOutput(config, emitted) await copy(config) await buildExe(config, chunks) diff --git a/src/config/types.ts b/src/config/types.ts index 697e3791b..7f589b353 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. 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 to output directory. * @default true */ clean?: boolean | string[] diff --git a/src/features/clean.ts b/src/features/clean.ts index 5ab936b03..e992456cc 100644 --- a/src/features/clean.ts +++ b/src/features/clean.ts @@ -4,52 +4,157 @@ 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 = /[/\\]$/ -export async function cleanOutDir(configs: ResolvedConfig[]): Promise { - const removes = new Set() +/** + * 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. + */ +const emittedByKey = new Map }>() - for (const config of configs) { - if (config.devtools && (config.devtools.clean ?? true)) { - config.clean.push('node_modules/.rolldown') - } +/** Builds that have been asked to run and have not settled yet. */ +const building = new Set() - if (config.exe) { - const exeOutDir = path.resolve(config.cwd, config.exe.outDir || 'build') - config.clean.push(exeOutDir) - } +export function markBuilding(config: ResolvedConfig): void { + building.add(outputKey(config)) +} - if (!config.clean.length) continue - const files = await glob(config.clean, { - cwd: config.cwd, - absolute: true, - onlyFiles: false, - dot: true, +/** + * 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), }) + } + building.delete(key) - const normalizedOutDir = config.outDir.replace(RE_LAST_SLASH, '') - for (const file of files) { - const normalizedFile = file.replace(RE_LAST_SLASH, '') - if (normalizedFile !== normalizedOutDir) { - removes.add(file) - } - } + if (!config.clean.length || isOutDirBusy(config)) return + await cleanPreservingOutputs(config) +} + +/** + * 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, +): Promise { + ensureExtraCleanTargets(config) + if (!config.clean.length) return + + 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 = [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 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 } - if (!removes.size) return - - globalLogger.info(`Cleaning ${removes.size} files`) - await Promise.all( - [...removes].map(async (file) => { - debug('Removing', file) - await fsRemove(file) - }), + 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), + ), + ), ) - debug('Removed %d files', removes.size) + 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' + 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( @@ -71,16 +176,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) - }), - ) -} 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 2b25cf6b8..156df813f 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,228 @@ 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('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"', + }) + 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 outputExistedAtRebuildStart = false + let wroteChange = 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() { + if (!wroteChange) return + outputExistedAtRebuildStart = await fsExists(outputPath) + }, + }, + ], + async onSuccess() { + 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( + 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() + } + }) + + 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() + } + }) })