Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
90 changes: 85 additions & 5 deletions integrations/cli/index.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -644,7 +644,7 @@ describe.each([
}
}
`,
'ssrc/index.html': html`
'src/index.html': html`
<div class="flex"></div>
`,
'src/index.css': css`
Expand Down Expand Up @@ -705,7 +705,7 @@ describe.each([
}
}
`,
'ssrc/index.html': html`
'src/index.html': html`
<div class="flex"></div>
`,
'src/index.css': css`
Expand Down Expand Up @@ -771,7 +771,7 @@ describe.each([
}
}
`,
'ssrc/index.html': html`
'src/index.html': html`
<div class="flex"></div>
`,
'src/index.css': css`
Expand Down Expand Up @@ -832,7 +832,7 @@ describe.each([
}
}
`,
'ssrc/index.html': html`
'src/index.html': html`
<div class="flex"></div>
`,
'src/index.css': css`
Expand Down Expand Up @@ -1065,7 +1065,7 @@ describe.each([
}
}
`,
'ssrc/index.html': html`
'src/index.html': html`
<div class="flex"></div>
`,
'src/index.css': css`
Expand Down Expand Up @@ -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(
Expand Down
5 changes: 5 additions & 0 deletions integrations/utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down Expand Up @@ -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')

Expand Down
91 changes: 81 additions & 10 deletions packages/@tailwindcss-cli/src/commands/build/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
}
Expand Down Expand Up @@ -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')
Expand Down Expand Up @@ -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] : []
Comment thread
coderabbitai[bot] marked this conversation as resolved.

// 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()
Expand Down Expand Up @@ -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)}`)
}
}),
)
Comment thread
greptile-apps[bot] marked this conversation as resolved.
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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`
}