diff --git a/CHANGELOG.md b/CHANGELOG.md
index 004ace8e5ee6..d17e911a8bdc 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -22,6 +22,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- Ensure `@tailwindcss/webpack` can be installed in Rspack projects without requiring `webpack` as a peer dependency ([#20027](https://github.com/tailwindlabs/tailwindcss/pull/20027))
- Canonicalization: don't suggest invalid `calc(…)` expressions (e.g. `px-[calc(1rem+0px)]` → `px-[calc(1rem+0)]`) ([#20127](https://github.com/tailwindlabs/tailwindcss/pull/20127))
- Canonicalization: avoid suggesting large spacing-scale values for arbitrary lengths (e.g. `left-[99999px]` → `left-[99999px]`, not `left-24999.75`) ([#20130](https://github.com/tailwindlabs/tailwindcss/pull/20130))
+- Ensure `@tailwindcss/cli` in `--watch` mode recovers when a tracked dependency is deleted and restored ([#20137](https://github.com/tailwindlabs/tailwindcss/pull/20137))
## [4.3.0] - 2026-05-08
diff --git a/integrations/cli/index.test.ts b/integrations/cli/index.test.ts
index f56cd765e8d7..af5406a8f8a6 100644
--- a/integrations/cli/index.test.ts
+++ b/integrations/cli/index.test.ts
@@ -644,7 +644,7 @@ describe.each([
}
}
`,
- 'ssrc/index.html': html`
+ 'src/index.html': html`
`,
'src/index.css': css`
@@ -705,7 +705,7 @@ describe.each([
}
}
`,
- 'ssrc/index.html': html`
+ 'src/index.html': html`
`,
'src/index.css': css`
@@ -771,7 +771,7 @@ describe.each([
}
}
`,
- 'ssrc/index.html': html`
+ 'src/index.html': html`
`,
'src/index.css': css`
@@ -832,7 +832,7 @@ describe.each([
}
}
`,
- 'ssrc/index.html': html`
+ 'src/index.html': html`
`,
'src/index.css': css`
@@ -1065,7 +1065,7 @@ describe.each([
}
}
`,
- 'ssrc/index.html': html`
+ 'src/index.html': html`
`,
'src/index.css': css`
@@ -1294,6 +1294,86 @@ describe.each([
})
},
)
+
+ test(
+ 'watch mode should trigger a full rebuild when a dependency is removed',
+ {
+ fs: {
+ 'package.json': json`
+ {
+ "dependencies": {
+ "tailwindcss": "workspace:^",
+ "@tailwindcss/cli": "workspace:^"
+ }
+ }
+ `,
+ 'src/index.html': html`
+
+ `,
+ 'src/index.css': css`
+ @import 'tailwindcss/utilities';
+ @config '../tailwind.config.js';
+ `,
+ 'tailwind.config.js': js`
+ const myColor = require('./my-color')
+
+ module.exports = {
+ theme: {
+ extend: {
+ colors: {
+ primary: myColor,
+ },
+ },
+ },
+ }
+ `,
+ 'my-color.js': js`
+ //
+ module.exports = 'blue'
+ `,
+ },
+ },
+ async ({ spawn, fs, expect }) => {
+ let process = await spawn(`${command} --input src/index.css --output dist/out.css --watch`)
+ await process.onStderr((m) => m.includes('Done in'))
+
+ expect(await fs.dumpFiles('dist/*.css')).toMatchInlineSnapshot(`
+ "
+ --- dist/out.css ---
+ .flex {
+ display: flex;
+ }
+ .text-primary {
+ color: blue;
+ }
+ "
+ `)
+
+ // Remove the dependency of the tailwind.config.js file
+ await fs.delete('my-color.js')
+
+ // We expect an error
+ await process.onStderr((m) => m.includes('Error'))
+ await process.onStderr((m) => m.includes('Done in'))
+
+ // Re-create the file to resolve the issue
+ await fs.write('my-color.js', js`module.exports = 'red'`)
+ await process.onStderr((m) => m.includes('Done in'))
+
+ // Expect a full rebuild
+ expect(await fs.dumpFiles('dist/*.css')).toMatchInlineSnapshot(`
+ "
+ --- dist/out.css ---
+ .flex {
+ display: flex;
+ }
+ .text-primary {
+ color: red;
+ }
+ "
+ `)
+ },
+ )
})
test(
diff --git a/integrations/utils.ts b/integrations/utils.ts
index c1e5e80628dc..7769d8f5ed10 100644
--- a/integrations/utils.ts
+++ b/integrations/utils.ts
@@ -51,6 +51,7 @@ interface TestContext {
write(filePath: string, content: string, encoding?: BufferEncoding): Promise
create(filePaths: string[]): Promise
read(filePath: string): Promise
+ delete(filePath: string): Promise
glob(pattern: string): Promise<[string, string][]>
dumpFiles(pattern: string): Promise
expectFileToContain(
@@ -328,6 +329,10 @@ export function test(
}
},
+ async delete(filename: string): Promise {
+ await fs.unlink(path.join(root, filename))
+ },
+
async read(filePath: string) {
let content = await fs.readFile(path.resolve(root, filePath), 'utf8')
diff --git a/packages/@tailwindcss-cli/src/commands/build/index.ts b/packages/@tailwindcss-cli/src/commands/build/index.ts
index 01d646a6abdf..ef5ac20ac71b 100644
--- a/packages/@tailwindcss-cli/src/commands/build/index.ts
+++ b/packages/@tailwindcss-cli/src/commands/build/index.ts
@@ -77,9 +77,13 @@ async function handleError(fn: () => T): Promise {
try {
return await fn()
} catch (err) {
- if (err instanceof Error) {
- eprintln(err.toString())
- }
+ eprintln(
+ [red('Error:'), dim('\u250C')]
+ .concat(`${err}`.split('\n').map((line) => `${dim('\u2502')} ${line}`))
+ .concat(dim('\u2514'))
+ .join('\n'),
+ )
+
process.exit(1)
}
}
@@ -219,6 +223,7 @@ export async function handle(args: Result>) {
let inputBasePath = inputFilePath ? path.dirname(inputFilePath) : process.cwd()
let fullRebuildPaths: string[] = inputFilePath ? [inputFilePath] : []
+ let backupRebuildPaths = fullRebuildPaths
async function createCompiler(css: string, I: Instrumentation) {
DEBUG && I.start('Setup compiler')
@@ -308,11 +313,23 @@ export async function handle(args: Result>) {
@import 'tailwindcss';
`
clearRequireCache(resolvedFullRebuildPaths)
+
+ // Track current rebuild paths in case something goes wrong when
+ // performing a full rebuild.
+ backupRebuildPaths = fullRebuildPaths.slice()
+
+ // The `inputFilePath`, if provided, will be the only known full
+ // rebuild path before the compiler is re-created.
fullRebuildPaths = inputFilePath ? [inputFilePath] : []
// Create a new compiler, given the new `input`
;[compiler, scanner] = await createCompiler(input, I)
+ // Succesfully created a new compiler, so the `fullRebuildPaths`
+ // will be updated. If other errors occur, we should be able to
+ // restore the paths unconditionally.
+ backupRebuildPaths = fullRebuildPaths.slice()
+
// Scan the directory for candidates
DEBUG && I.start('Scan for candidates')
let candidates = scanner.scan()
@@ -372,11 +389,51 @@ export async function handle(args: Result>) {
let end = process.hrtime.bigint()
if (!args['--silent']) eprintln(`Done in ${formatDuration(end - start)}`)
} catch (err) {
+ // It's important that we perform a full rebuild when any of the
+ // dependencies tracked in `fullRebuildPaths` has been changed.
+ //
+ // If we remove one of those files, then a subsequent build will be
+ // triggered, but it will fail because the dependency is gone. The
+ // compiler itself will be in a broken state and won't be able to
+ // register any dependencies therefore we want to restore all the
+ // dependencies from before. If we don't do that, then we won't be
+ // able to recover from a bug in a transitive dependency.
+ //
+ // E.g.:
+ // ```css
+ // /* input.css — known full rebuild path */
+ // @import 'tailwindcss';
+ // @config "./tailwind.config.js";
+ // ```
+ //
+ // ```js
+ // // tailwind.config.js
+ // const theme = require('./my-theme.js');
+ //
+ // module.exports = {
+ // theme
+ // }
+ // ```
+ // In this case `my-theme.js` is a transitive dependency of
+ // `input.css` via `tailwind.config.js`. Removing `my-theme.js` will
+ // result in an error, restoring `my-theme.js` should trigger a
+ // fresh build even though the compiler didn't restore.
+ //
+ // Once the build error is fixed, a fresh full rebuild will happen
+ // which in turn will fixup the full rebuild paths.
+ fullRebuildPaths = backupRebuildPaths
+
// Catch any errors and print them to stderr, but don't exit the process
// and keep watching.
- if (err instanceof Error) {
- eprintln(err.toString())
- }
+ eprintln(
+ [red('Error:'), dim('\u250C')]
+ .concat(`${err}`.split('\n').map((line) => `${dim('\u2502')} ${line}`))
+ .concat(dim('\u2514'))
+ .join('\n'),
+ )
+
+ let end = process.hrtime.bigint()
+ if (!args['--silent']) eprintln(`Done in ${formatDuration(end - start)}`)
}
}),
)
@@ -479,10 +536,16 @@ 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
+ // When a file is deleted, a rebuild should be triggered such that we
+ // can figure out whether this file must trigger a fresh build or not.
+ //
+ // If it must trigger a fresh build, then we will temporarily end up
+ // in a broken state, but an error will be shown to the user. Once the
+ // user resolves the issue, the CLI will recover.
+ if (event.type === 'delete') {
+ files.add(event.path)
+ return
+ }
// Ignore directory changes. We only care about file changes
let stats: Stats | null = null
@@ -516,3 +579,11 @@ async function createWatchers(dirs: string[], cb: (files: string[]) => void) {
function watchDirectories(scanner: Scanner) {
return [...new Set(scanner.normalizedSources.flatMap((globEntry) => globEntry.base))]
}
+
+function dim(str: string) {
+ return `\x1B[2m${str}\x1B[22m`
+}
+
+function red(str: string) {
+ return `\x1B[31m${str}\x1B[39m`
+}