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.
This commit is contained in:
parent
6b43b6400a
commit
44818a6cf6
4 changed files with 172 additions and 15 deletions
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -644,7 +644,7 @@ describe.each([
|
|||
}
|
||||
}
|
||||
`,
|
||||
'ssrc/index.html': html`
|
||||
'src/index.html': html`
|
||||
<div class="flex"></div>
|
||||
`,
|
||||
'src/index.css': css`
|
||||
|
|
@ -705,7 +705,7 @@ describe.each([
|
|||
}
|
||||
}
|
||||
`,
|
||||
'ssrc/index.html': html`
|
||||
'src/index.html': html`
|
||||
<div class="flex"></div>
|
||||
`,
|
||||
'src/index.css': css`
|
||||
|
|
@ -771,7 +771,7 @@ describe.each([
|
|||
}
|
||||
}
|
||||
`,
|
||||
'ssrc/index.html': html`
|
||||
'src/index.html': html`
|
||||
<div class="flex"></div>
|
||||
`,
|
||||
'src/index.css': css`
|
||||
|
|
@ -832,7 +832,7 @@ describe.each([
|
|||
}
|
||||
}
|
||||
`,
|
||||
'ssrc/index.html': html`
|
||||
'src/index.html': html`
|
||||
<div class="flex"></div>
|
||||
`,
|
||||
'src/index.css': css`
|
||||
|
|
@ -1065,7 +1065,7 @@ describe.each([
|
|||
}
|
||||
}
|
||||
`,
|
||||
'ssrc/index.html': html`
|
||||
'src/index.html': html`
|
||||
<div class="flex"></div>
|
||||
`,
|
||||
'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`
|
||||
<div class="flex text-primary"></div>
|
||||
`,
|
||||
'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(
|
||||
|
|
|
|||
|
|
@ -51,6 +51,7 @@ interface TestContext {
|
|||
write(filePath: string, content: string, encoding?: BufferEncoding): Promise<void>
|
||||
create(filePaths: string[]): Promise<void>
|
||||
read(filePath: string): Promise<string>
|
||||
delete(filePath: string): Promise<void>
|
||||
glob(pattern: string): Promise<[string, string][]>
|
||||
dumpFiles(pattern: string): Promise<string>
|
||||
expectFileToContain(
|
||||
|
|
@ -328,6 +329,10 @@ export function test(
|
|||
}
|
||||
},
|
||||
|
||||
async delete(filename: string): Promise<void> {
|
||||
await fs.unlink(path.join(root, filename))
|
||||
},
|
||||
|
||||
async read(filePath: string) {
|
||||
let content = await fs.readFile(path.resolve(root, filePath), 'utf8')
|
||||
|
||||
|
|
|
|||
|
|
@ -77,9 +77,13 @@ async function handleError<T>(fn: () => T): Promise<T> {
|
|||
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<ReturnType<typeof options>>) {
|
|||
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<ReturnType<typeof options>>) {
|
|||
@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<ReturnType<typeof options>>) {
|
|||
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`
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue