Skip to content

Commit 44818a6

Browse files
authored
Ensure @tailwindcss/cli recovers from a deleted transitive dependency (#20137)
This PR fixes an issue where the `@tailwindcss/cli` can get into a non-recoverable state when any of the transitive dependencies break. Tailwind CSS has 2 kinds of dependencies: 1. All your templates 2. All dependencies that contribute to your configuration such as the `input.css`, any plugins, any `tailwind.config.js` files and so on. When a template changes, we just have to scan for new Tailwind CSS classes and emit a new CSS file. But when the `input.css` file, or any of its dependencies changes, then we want to perform a full rebuild. The idea is that your `@theme` might have changed, or new plugins have been added, or old plugins have been removed. If you have an `input.css` file: ```css @import "tailwindcss"; @config "./tailwind.config.js"; ``` That relies on a custom config: `tailwind.config.js`: ```js const theme = require('./my-custom-theme.js'); module.exports = { theme } ``` If that file relies on yet another file: `./my-custom-theme.js`, then changes there should also trigger a full rebuild. Since we're dealing with JavaScript here, we want to clear the require cache and rebuild the dependency tree such that another change to any of these files triggers a full fresh build. However, if any of those (transitive) dependencies are deleted, then we will end up in an invalid state. Creating a new compiler will result in a build error. The compiler won't be able to figure out the entire dependency tree, and we're stuck. Once the user fixes the potentially missing dependency, the watchers will not be watching any of those files because we created a fresh compiler. With this PR, we fix that by keeping track of old paths and using those while we are still in an invalid state. The moment everything is fixed, a fresh dependency tree is created and everything starts working again without you having to restart the `@tailwindcss/cli` command. Fixes: #20113 Closes: #20114 Closes: #20133 ## Test plan - Added an integration test that removes the transitive dependency. Re-adding that file later will recover the CLI state.
1 parent 6b43b64 commit 44818a6

4 files changed

Lines changed: 172 additions & 15 deletions

File tree

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
2222
- Ensure `@tailwindcss/webpack` can be installed in Rspack projects without requiring `webpack` as a peer dependency ([#20027](https://github.com/tailwindlabs/tailwindcss/pull/20027))
2323
- 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))
2424
- 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))
25+
- Ensure `@tailwindcss/cli` in `--watch` mode recovers when a tracked dependency is deleted and restored ([#20137](https://github.com/tailwindlabs/tailwindcss/pull/20137))
2526

2627
## [4.3.0] - 2026-05-08
2728

integrations/cli/index.test.ts

Lines changed: 85 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -644,7 +644,7 @@ describe.each([
644644
}
645645
}
646646
`,
647-
'ssrc/index.html': html`
647+
'src/index.html': html`
648648
<div class="flex"></div>
649649
`,
650650
'src/index.css': css`
@@ -705,7 +705,7 @@ describe.each([
705705
}
706706
}
707707
`,
708-
'ssrc/index.html': html`
708+
'src/index.html': html`
709709
<div class="flex"></div>
710710
`,
711711
'src/index.css': css`
@@ -771,7 +771,7 @@ describe.each([
771771
}
772772
}
773773
`,
774-
'ssrc/index.html': html`
774+
'src/index.html': html`
775775
<div class="flex"></div>
776776
`,
777777
'src/index.css': css`
@@ -832,7 +832,7 @@ describe.each([
832832
}
833833
}
834834
`,
835-
'ssrc/index.html': html`
835+
'src/index.html': html`
836836
<div class="flex"></div>
837837
`,
838838
'src/index.css': css`
@@ -1065,7 +1065,7 @@ describe.each([
10651065
}
10661066
}
10671067
`,
1068-
'ssrc/index.html': html`
1068+
'src/index.html': html`
10691069
<div class="flex"></div>
10701070
`,
10711071
'src/index.css': css`
@@ -1294,6 +1294,86 @@ describe.each([
12941294
})
12951295
},
12961296
)
1297+
1298+
test(
1299+
'watch mode should trigger a full rebuild when a dependency is removed',
1300+
{
1301+
fs: {
1302+
'package.json': json`
1303+
{
1304+
"dependencies": {
1305+
"tailwindcss": "workspace:^",
1306+
"@tailwindcss/cli": "workspace:^"
1307+
}
1308+
}
1309+
`,
1310+
'src/index.html': html`
1311+
<div class="flex text-primary"></div>
1312+
`,
1313+
'src/index.css': css`
1314+
@import 'tailwindcss/utilities';
1315+
@config '../tailwind.config.js';
1316+
`,
1317+
'tailwind.config.js': js`
1318+
const myColor = require('./my-color')
1319+
1320+
module.exports = {
1321+
theme: {
1322+
extend: {
1323+
colors: {
1324+
primary: myColor,
1325+
},
1326+
},
1327+
},
1328+
}
1329+
`,
1330+
'my-color.js': js`
1331+
//
1332+
module.exports = 'blue'
1333+
`,
1334+
},
1335+
},
1336+
async ({ spawn, fs, expect }) => {
1337+
let process = await spawn(`${command} --input src/index.css --output dist/out.css --watch`)
1338+
await process.onStderr((m) => m.includes('Done in'))
1339+
1340+
expect(await fs.dumpFiles('dist/*.css')).toMatchInlineSnapshot(`
1341+
"
1342+
--- dist/out.css ---
1343+
.flex {
1344+
display: flex;
1345+
}
1346+
.text-primary {
1347+
color: blue;
1348+
}
1349+
"
1350+
`)
1351+
1352+
// Remove the dependency of the tailwind.config.js file
1353+
await fs.delete('my-color.js')
1354+
1355+
// We expect an error
1356+
await process.onStderr((m) => m.includes('Error'))
1357+
await process.onStderr((m) => m.includes('Done in'))
1358+
1359+
// Re-create the file to resolve the issue
1360+
await fs.write('my-color.js', js`module.exports = 'red'`)
1361+
await process.onStderr((m) => m.includes('Done in'))
1362+
1363+
// Expect a full rebuild
1364+
expect(await fs.dumpFiles('dist/*.css')).toMatchInlineSnapshot(`
1365+
"
1366+
--- dist/out.css ---
1367+
.flex {
1368+
display: flex;
1369+
}
1370+
.text-primary {
1371+
color: red;
1372+
}
1373+
"
1374+
`)
1375+
},
1376+
)
12971377
})
12981378

12991379
test(

integrations/utils.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -51,6 +51,7 @@ interface TestContext {
5151
write(filePath: string, content: string, encoding?: BufferEncoding): Promise<void>
5252
create(filePaths: string[]): Promise<void>
5353
read(filePath: string): Promise<string>
54+
delete(filePath: string): Promise<void>
5455
glob(pattern: string): Promise<[string, string][]>
5556
dumpFiles(pattern: string): Promise<string>
5657
expectFileToContain(
@@ -328,6 +329,10 @@ export function test(
328329
}
329330
},
330331

332+
async delete(filename: string): Promise<void> {
333+
await fs.unlink(path.join(root, filename))
334+
},
335+
331336
async read(filePath: string) {
332337
let content = await fs.readFile(path.resolve(root, filePath), 'utf8')
333338

packages/@tailwindcss-cli/src/commands/build/index.ts

Lines changed: 81 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -77,9 +77,13 @@ async function handleError<T>(fn: () => T): Promise<T> {
7777
try {
7878
return await fn()
7979
} catch (err) {
80-
if (err instanceof Error) {
81-
eprintln(err.toString())
82-
}
80+
eprintln(
81+
[red('Error:'), dim('\u250C')]
82+
.concat(`${err}`.split('\n').map((line) => `${dim('\u2502')} ${line}`))
83+
.concat(dim('\u2514'))
84+
.join('\n'),
85+
)
86+
8387
process.exit(1)
8488
}
8589
}
@@ -219,6 +223,7 @@ export async function handle(args: Result<ReturnType<typeof options>>) {
219223
let inputBasePath = inputFilePath ? path.dirname(inputFilePath) : process.cwd()
220224

221225
let fullRebuildPaths: string[] = inputFilePath ? [inputFilePath] : []
226+
let backupRebuildPaths = fullRebuildPaths
222227

223228
async function createCompiler(css: string, I: Instrumentation) {
224229
DEBUG && I.start('Setup compiler')
@@ -308,11 +313,23 @@ export async function handle(args: Result<ReturnType<typeof options>>) {
308313
@import 'tailwindcss';
309314
`
310315
clearRequireCache(resolvedFullRebuildPaths)
316+
317+
// Track current rebuild paths in case something goes wrong when
318+
// performing a full rebuild.
319+
backupRebuildPaths = fullRebuildPaths.slice()
320+
321+
// The `inputFilePath`, if provided, will be the only known full
322+
// rebuild path before the compiler is re-created.
311323
fullRebuildPaths = inputFilePath ? [inputFilePath] : []
312324

313325
// Create a new compiler, given the new `input`
314326
;[compiler, scanner] = await createCompiler(input, I)
315327

328+
// Succesfully created a new compiler, so the `fullRebuildPaths`
329+
// will be updated. If other errors occur, we should be able to
330+
// restore the paths unconditionally.
331+
backupRebuildPaths = fullRebuildPaths.slice()
332+
316333
// Scan the directory for candidates
317334
DEBUG && I.start('Scan for candidates')
318335
let candidates = scanner.scan()
@@ -372,11 +389,51 @@ export async function handle(args: Result<ReturnType<typeof options>>) {
372389
let end = process.hrtime.bigint()
373390
if (!args['--silent']) eprintln(`Done in ${formatDuration(end - start)}`)
374391
} catch (err) {
392+
// It's important that we perform a full rebuild when any of the
393+
// dependencies tracked in `fullRebuildPaths` has been changed.
394+
//
395+
// If we remove one of those files, then a subsequent build will be
396+
// triggered, but it will fail because the dependency is gone. The
397+
// compiler itself will be in a broken state and won't be able to
398+
// register any dependencies therefore we want to restore all the
399+
// dependencies from before. If we don't do that, then we won't be
400+
// able to recover from a bug in a transitive dependency.
401+
//
402+
// E.g.:
403+
// ```css
404+
// /* input.css — known full rebuild path */
405+
// @import 'tailwindcss';
406+
// @config "./tailwind.config.js";
407+
// ```
408+
//
409+
// ```js
410+
// // tailwind.config.js
411+
// const theme = require('./my-theme.js');
412+
//
413+
// module.exports = {
414+
// theme
415+
// }
416+
// ```
417+
// In this case `my-theme.js` is a transitive dependency of
418+
// `input.css` via `tailwind.config.js`. Removing `my-theme.js` will
419+
// result in an error, restoring `my-theme.js` should trigger a
420+
// fresh build even though the compiler didn't restore.
421+
//
422+
// Once the build error is fixed, a fresh full rebuild will happen
423+
// which in turn will fixup the full rebuild paths.
424+
fullRebuildPaths = backupRebuildPaths
425+
375426
// Catch any errors and print them to stderr, but don't exit the process
376427
// and keep watching.
377-
if (err instanceof Error) {
378-
eprintln(err.toString())
379-
}
428+
eprintln(
429+
[red('Error:'), dim('\u250C')]
430+
.concat(`${err}`.split('\n').map((line) => `${dim('\u2502')} ${line}`))
431+
.concat(dim('\u2514'))
432+
.join('\n'),
433+
)
434+
435+
let end = process.hrtime.bigint()
436+
if (!args['--silent']) eprintln(`Done in ${formatDuration(end - start)}`)
380437
}
381438
}),
382439
)
@@ -479,10 +536,16 @@ async function createWatchers(dirs: string[], cb: (files: string[]) => void) {
479536

480537
await Promise.all(
481538
events.map(async (event) => {
482-
// We currently don't handle deleted files because it doesn't influence
483-
// the CSS output. This is because we currently keep all scanned
484-
// candidates in a cache for performance reasons.
485-
if (event.type === 'delete') return
539+
// When a file is deleted, a rebuild should be triggered such that we
540+
// can figure out whether this file must trigger a fresh build or not.
541+
//
542+
// If it must trigger a fresh build, then we will temporarily end up
543+
// in a broken state, but an error will be shown to the user. Once the
544+
// user resolves the issue, the CLI will recover.
545+
if (event.type === 'delete') {
546+
files.add(event.path)
547+
return
548+
}
486549

487550
// Ignore directory changes. We only care about file changes
488551
let stats: Stats | null = null
@@ -516,3 +579,11 @@ async function createWatchers(dirs: string[], cb: (files: string[]) => void) {
516579
function watchDirectories(scanner: Scanner) {
517580
return [...new Set(scanner.normalizedSources.flatMap((globEntry) => globEntry.base))]
518581
}
582+
583+
function dim(str: string) {
584+
return `\x1B[2m${str}\x1B[22m`
585+
}
586+
587+
function red(str: string) {
588+
return `\x1B[31m${str}\x1B[39m`
589+
}

0 commit comments

Comments
 (0)