From a8dceeab6d9bee5ce3c46a955560f0ed3b3e1826 Mon Sep 17 00:00:00 2001 From: Minh Vu Date: Tue, 26 May 2026 00:53:47 +0200 Subject: [PATCH 1/2] Handle deleted rebuild dependencies in CLI watch --- integrations/cli/config.test.ts | 67 +++++++++++++++++++ .../src/commands/build/index.ts | 45 ++++++++----- 2 files changed, 96 insertions(+), 16 deletions(-) diff --git a/integrations/cli/config.test.ts b/integrations/cli/config.test.ts index dda70814bb6d..5d56544c2440 100644 --- a/integrations/cli/config.test.ts +++ b/integrations/cli/config.test.ts @@ -248,6 +248,73 @@ test( }, ) +test( + 'Config dependency deletion triggers a rebuild error in watch mode', + { + fs: { + 'package.json': json` + { + "dependencies": { + "tailwindcss": "workspace:^", + "@tailwindcss/cli": "workspace:^" + } + } + `, + 'index.html': html` +
+ `, + 'tailwind.config.js': js` + const myColor = require('./my-color') + module.exports = { + theme: { + extend: { + colors: { + primary: myColor, + }, + }, + }, + } + `, + 'my-color.js': js`module.exports = 'blue'`, + 'src/index.css': css` + @import 'tailwindcss'; + @config '../tailwind.config.js'; + `, + }, + }, + async ({ fs, exec, spawn }) => { + let process = await spawn( + 'pnpm tailwindcss --input src/index.css --output dist/out.css --watch', + ) + await process.onStderr((m) => m.includes('Done in')) + + await fs.expectFileToContain('dist/out.css', [ + // + candidate`text-primary`, + 'color: blue', + ]) + + process.flush() + await exec(`node -e "require('node:fs').unlinkSync('my-color.js')"`) + await process.onStderr((m) => m.includes('ENOENT') && m.includes('my-color.js')) + + await fs.write('my-color.js', js`module.exports = 'red'`) + await fs.write( + 'src/index.css', + css` + @import 'tailwindcss'; + @config '../tailwind.config.js'; + `, + ) + + await fs.expectFileToContain('dist/out.css', [ + // + candidate`text-primary`, + 'color: red', + ]) + }, +) + test( 'Config files (ESM, watch mode)', { diff --git a/packages/@tailwindcss-cli/src/commands/build/index.ts b/packages/@tailwindcss-cli/src/commands/build/index.ts index 01d646a6abdf..2c6b9ab4c16d 100644 --- a/packages/@tailwindcss-cli/src/commands/build/index.ts +++ b/packages/@tailwindcss-cli/src/commands/build/index.ts @@ -27,6 +27,11 @@ import { drainStdin, outputFile } from './utils' const css = String.raw const DEBUG = env.DEBUG +type WatchEvent = { + path: string + type: 'create' | 'update' | 'delete' +} + export function options() { return { '--input': { @@ -257,11 +262,11 @@ export async function handle(args: Result>) { if (args['--watch']) { let cleanupWatchers: (() => Promise)[] = [] cleanupWatchers.push( - await createWatchers(watchDirectories(scanner), async function handle(files) { + await createWatchers(watchDirectories(scanner), async function handle(events) { try { // If the only change happened to the output file, then we don't want to // trigger a rebuild because that will result in an infinite loop. - if (files.length === 1 && files[0] === args['--output']) return + if (events.length === 1 && events[0].path === args['--output']) return using I = new Instrumentation() DEBUG && I.start('[@tailwindcss/cli] (watcher)') @@ -274,11 +279,11 @@ export async function handle(args: Result>) { let resolvedFullRebuildPaths = fullRebuildPaths - for (let file of files) { + for (let event of events) { // If one of the changed files is related to the input CSS or JS // config/plugin files, then we need to do a full rebuild because // the theme might have changed. - if (resolvedFullRebuildPaths.includes(file)) { + if (resolvedFullRebuildPaths.includes(event.path)) { rebuildStrategy = 'full' // No need to check the rest of the events, because we already know we @@ -286,10 +291,14 @@ export async function handle(args: Result>) { break } + // We currently keep scanned candidates cached, so deleting a content + // file does not require an incremental rebuild. + if (event.type === 'delete') continue + // Track new and updated files for incremental rebuilds. changedFiles.push({ - file, - extension: path.extname(file).slice(1), + file: event.path, + extension: path.extname(event.path).slice(1), } satisfies ChangedContent) } @@ -417,7 +426,7 @@ export async function handle(args: Result>) { if (!args['--silent']) eprintln(`Done in ${formatDuration(end - start)}`) } -async function createWatchers(dirs: string[], cb: (files: string[]) => void) { +async function createWatchers(dirs: string[], cb: (events: WatchEvent[]) => void) { // Remove any directories that are children of an already watched directory. // If we don't we may not get notified of certain filesystem events regardless // of whether or not they are for the directory that is duplicated. @@ -447,8 +456,8 @@ async function createWatchers(dirs: string[], cb: (files: string[]) => void) { // we want to cleanup the old ones we captured here. let watchers = new Disposables() - // Track all files that were added or changed. - let files = new Set() + // Track the latest relevant event for each path. + let trackedEvents = new Map() // Keep track of the debounce queue to avoid multiple rebuilds. let debounceQueue = new Disposables() @@ -462,8 +471,8 @@ async function createWatchers(dirs: string[], cb: (files: string[]) => void) { // Setup a new macrotask to handle the files in batch. debounceQueue.queueMacrotask(() => { - cb(Array.from(files)) - files.clear() + cb(Array.from(trackedEvents, ([path, type]) => ({ path, type }))) + trackedEvents.clear() }) } @@ -479,10 +488,14 @@ async function createWatchers(dirs: string[], cb: (files: string[]) => void) { await Promise.all( events.map(async (event) => { - // We currently don't handle deleted files because it doesn't influence - // the CSS output. This is because we currently keep all scanned - // candidates in a cache for performance reasons. - if (event.type === 'delete') return + // We currently keep scanned candidates cached, so deleting a content + // file does not influence the CSS output. However, tracked full + // rebuild dependencies still need to be forwarded to the caller. + if (event.type === 'delete') { + let existing = trackedEvents.get(event.path) + trackedEvents.set(event.path, existing ?? event.type) + return + } // Ignore directory changes. We only care about file changes let stats: Stats | null = null @@ -494,7 +507,7 @@ async function createWatchers(dirs: string[], cb: (files: string[]) => void) { } // Track the changed file. - files.add(event.path) + trackedEvents.set(event.path, event.type) }), ) From 05579f3ea2958e7c70fe6d84922263749c81fbab Mon Sep 17 00:00:00 2001 From: Minh Vu Date: Tue, 26 May 2026 01:05:02 +0200 Subject: [PATCH 2/2] Tighten delete event coalescing in CLI watch --- integrations/cli/config.test.ts | 2 ++ packages/@tailwindcss-cli/src/commands/build/index.ts | 3 +-- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/integrations/cli/config.test.ts b/integrations/cli/config.test.ts index 5d56544c2440..642959059280 100644 --- a/integrations/cli/config.test.ts +++ b/integrations/cli/config.test.ts @@ -299,6 +299,8 @@ test( await process.onStderr((m) => m.includes('ENOENT') && m.includes('my-color.js')) await fs.write('my-color.js', js`module.exports = 'red'`) + // Touch the input CSS to force a new full rebuild after the failed reload. + // At this point the missing dependency is no longer tracked yet. await fs.write( 'src/index.css', css` diff --git a/packages/@tailwindcss-cli/src/commands/build/index.ts b/packages/@tailwindcss-cli/src/commands/build/index.ts index 2c6b9ab4c16d..5ffcdcb77b52 100644 --- a/packages/@tailwindcss-cli/src/commands/build/index.ts +++ b/packages/@tailwindcss-cli/src/commands/build/index.ts @@ -492,8 +492,7 @@ async function createWatchers(dirs: string[], cb: (events: WatchEvent[]) => void // file does not influence the CSS output. However, tracked full // rebuild dependencies still need to be forwarded to the caller. if (event.type === 'delete') { - let existing = trackedEvents.get(event.path) - trackedEvents.set(event.path, existing ?? event.type) + trackedEvents.set(event.path, event.type) return }