diff --git a/CHANGELOG.md b/CHANGELOG.md
index 6b8158e2edc3..5a95aac3d4b4 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -18,6 +18,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- Hide default shadow suggestions when missing theme keys ([#17743](https://github.com/tailwindlabs/tailwindcss/pull/17743))
- Replace `_` with `.` in theme suggestions for `@utility` if surrounded by digits ([#17743](https://github.com/tailwindlabs/tailwindcss/pull/17743))
- Upgrade: Bump all Tailwind CSS related dependencies during upgrade ([#17763](https://github.com/tailwindlabs/tailwindcss/pull/17763))
+- PostCSS: Ensure that errors in stylesheet dependencies are recoverable ([#17754](https://github.com/tailwindlabs/tailwindcss/pull/17754))
## [4.1.4] - 2025-04-14
diff --git a/integrations/postcss/index.test.ts b/integrations/postcss/index.test.ts
index 63a0f019eaf8..9a6e8b4c4e11 100644
--- a/integrations/postcss/index.test.ts
+++ b/integrations/postcss/index.test.ts
@@ -635,3 +635,91 @@ test(
await fs.expectFileToContain('project-a/dist/out.css', [candidate`content-['c/src/index.js']`])
},
)
+
+test(
+ 'rebuild error recovery',
+ {
+ fs: {
+ 'package.json': json`
+ {
+ "devDependencies": {
+ "postcss": "^8",
+ "postcss-cli": "^10",
+ "tailwindcss": "workspace:^",
+ "@tailwindcss/postcss": "workspace:^"
+ }
+ }
+ `,
+ 'postcss.config.js': js`
+ module.exports = {
+ plugins: {
+ '@tailwindcss/postcss': {},
+ },
+ }
+ `,
+ 'src/index.html': html`
+
+ `,
+ 'src/index.css': css` @import './tailwind.css'; `,
+ 'src/tailwind.css': css`
+ @reference 'tailwindcss/does-not-exist';
+ @import 'tailwindcss/utilities';
+ `,
+ },
+ },
+ async ({ fs, expect, spawn }) => {
+ let process = await spawn('pnpm postcss src/index.css --output dist/out.css --watch --verbose')
+
+ await process.onStderr((message) =>
+ message.includes('Package path ./does-not-exist is not exported from package'),
+ )
+
+ expect(await fs.dumpFiles('dist/*.css')).toMatchInlineSnapshot(`
+ "
+ --- dist/out.css ---
+
+ "
+ `)
+
+ await process.onStderr((message) => message.includes('Waiting for file changes...'))
+
+ // Fix the CSS file
+ await fs.write(
+ 'src/tailwind.css',
+ css`
+ @reference 'tailwindcss/theme';
+ @import 'tailwindcss/utilities';
+ `,
+ )
+ await process.onStderr((message) => message.includes('Finished src/index.css'))
+
+ expect(await fs.dumpFiles('dist/*.css')).toMatchInlineSnapshot(`
+ "
+ --- dist/out.css ---
+ .underline {
+ text-decoration-line: underline;
+ }
+ "
+ `)
+
+ // Now break the CSS file again
+ await fs.write(
+ 'src/tailwind.css',
+ css`
+ @reference 'tailwindcss/does-not-exist';
+ @import 'tailwindcss/utilities';
+ `,
+ )
+ await process.onStderr((message) =>
+ message.includes('Package path ./does-not-exist is not exported from package'),
+ )
+ await process.onStderr((message) => message.includes('Finished src/index.css'))
+
+ expect(await fs.dumpFiles('dist/*.css')).toMatchInlineSnapshot(`
+ "
+ --- dist/out.css ---
+
+ "
+ `)
+ },
+)
diff --git a/packages/@tailwindcss-postcss/src/index.ts b/packages/@tailwindcss-postcss/src/index.ts
index bac5c5b9a9f9..b06c15cb237b 100644
--- a/packages/@tailwindcss-postcss/src/index.ts
+++ b/packages/@tailwindcss-postcss/src/index.ts
@@ -103,6 +103,10 @@ function tailwindcss(opts: PluginOptions = {}): AcceptedPlugin {
let context = getContextFromCache(inputFile, opts)
let inputBasePath = path.dirname(path.resolve(inputFile))
+ // Whether this is the first build or not, if it is, then we can
+ // optimize the build by not creating the compiler until we need it.
+ let isInitialBuild = context.compiler === null
+
async function createCompiler() {
DEBUG && I.start('Setup compiler')
if (context.fullRebuildPaths.length > 0 && !isInitialBuild) {
@@ -119,9 +123,7 @@ function tailwindcss(opts: PluginOptions = {}): AcceptedPlugin {
let compiler = await compileAst(ast, {
base: inputBasePath,
shouldRewriteUrls: true,
- onDependency: (path) => {
- context.fullRebuildPaths.push(path)
- },
+ onDependency: (path) => context.fullRebuildPaths.push(path),
// In CSS Module files, we have to disable the `@property` polyfill since these will
// emit global `*` rules which are considered to be non-pure and will cause builds
// to fail.
@@ -133,190 +135,207 @@ function tailwindcss(opts: PluginOptions = {}): AcceptedPlugin {
return compiler
}
- // Whether this is the first build or not, if it is, then we can
- // optimize the build by not creating the compiler until we need it.
- let isInitialBuild = context.compiler === null
+ try {
+ // Setup the compiler if it doesn't exist yet. This way we can
+ // guarantee a `build()` function is available.
+ context.compiler ??= createCompiler()
- // Setup the compiler if it doesn't exist yet. This way we can
- // guarantee a `build()` function is available.
- context.compiler ??= createCompiler()
+ if ((await context.compiler).features === Features.None) {
+ return
+ }
- if ((await context.compiler).features === Features.None) {
- return
- }
+ let rebuildStrategy: 'full' | 'incremental' = 'incremental'
- let rebuildStrategy: 'full' | 'incremental' = 'incremental'
+ // Track file modification times to CSS files
+ DEBUG && I.start('Register full rebuild paths')
+ {
+ for (let file of context.fullRebuildPaths) {
+ result.messages.push({
+ type: 'dependency',
+ plugin: '@tailwindcss/postcss',
+ file: path.resolve(file),
+ parent: result.opts.from,
+ })
+ }
- // Track file modification times to CSS files
- DEBUG && I.start('Register full rebuild paths')
- {
- for (let file of context.fullRebuildPaths) {
- result.messages.push({
- type: 'dependency',
- plugin: '@tailwindcss/postcss',
- file: path.resolve(file),
- parent: result.opts.from,
+ let files = result.messages.flatMap((message) => {
+ if (message.type !== 'dependency') return []
+ return message.file
})
- }
-
- let files = result.messages.flatMap((message) => {
- if (message.type !== 'dependency') return []
- return message.file
- })
- files.push(inputFile)
-
- for (let file of files) {
- let changedTime = fs.statSync(file, { throwIfNoEntry: false })?.mtimeMs ?? null
- if (changedTime === null) {
- if (file === inputFile) {
- rebuildStrategy = 'full'
+ files.push(inputFile)
+
+ for (let file of files) {
+ let changedTime = fs.statSync(file, { throwIfNoEntry: false })?.mtimeMs ?? null
+ if (changedTime === null) {
+ if (file === inputFile) {
+ rebuildStrategy = 'full'
+ }
+ continue
}
- continue
- }
- let prevTime = context.mtimes.get(file)
- if (prevTime === changedTime) continue
+ let prevTime = context.mtimes.get(file)
+ if (prevTime === changedTime) continue
- rebuildStrategy = 'full'
- context.mtimes.set(file, changedTime)
+ rebuildStrategy = 'full'
+ context.mtimes.set(file, changedTime)
+ }
+ }
+ DEBUG && I.end('Register full rebuild paths')
+
+ if (
+ rebuildStrategy === 'full' &&
+ // We can re-use the compiler if it was created during the
+ // initial build. If it wasn't, we need to create a new one.
+ !isInitialBuild
+ ) {
+ context.compiler = createCompiler()
}
- }
- DEBUG && I.end('Register full rebuild paths')
-
- if (
- rebuildStrategy === 'full' &&
- // We can re-use the compiler if it was created during the
- // initial build. If it wasn't, we need to create a new one.
- !isInitialBuild
- ) {
- context.compiler = createCompiler()
- }
- let compiler = await context.compiler
+ let compiler = await context.compiler
- if (context.scanner === null || rebuildStrategy === 'full') {
- DEBUG && I.start('Setup scanner')
- let sources = (() => {
- // Disable auto source detection
- if (compiler.root === 'none') {
- return []
- }
-
- // No root specified, use the base directory
- if (compiler.root === null) {
- return [{ base, pattern: '**/*', negated: false }]
- }
+ if (context.scanner === null || rebuildStrategy === 'full') {
+ DEBUG && I.start('Setup scanner')
+ let sources = (() => {
+ // Disable auto source detection
+ if (compiler.root === 'none') {
+ return []
+ }
- // Use the specified root
- return [{ ...compiler.root, negated: false }]
- })().concat(compiler.sources)
+ // No root specified, use the base directory
+ if (compiler.root === null) {
+ return [{ base, pattern: '**/*', negated: false }]
+ }
- // Look for candidates used to generate the CSS
- context.scanner = new Scanner({ sources })
- DEBUG && I.end('Setup scanner')
- }
+ // Use the specified root
+ return [{ ...compiler.root, negated: false }]
+ })().concat(compiler.sources)
- DEBUG && I.start('Scan for candidates')
- let candidates = compiler.features & Features.Utilities ? context.scanner.scan() : []
- DEBUG && I.end('Scan for candidates')
-
- if (compiler.features & Features.Utilities) {
- DEBUG && I.start('Register dependency messages')
- // Add all found files as direct dependencies
- // Note: With Turbopack, the input file might not be a resolved path
- let resolvedInputFile = path.resolve(base, inputFile)
- for (let file of context.scanner.files) {
- let absolutePath = path.resolve(file)
- // The CSS file cannot be a dependency of itself
- if (absolutePath === resolvedInputFile) {
- continue
- }
- result.messages.push({
- type: 'dependency',
- plugin: '@tailwindcss/postcss',
- file: absolutePath,
- parent: result.opts.from,
- })
+ // Look for candidates used to generate the CSS
+ context.scanner = new Scanner({ sources })
+ DEBUG && I.end('Setup scanner')
}
- // Register dependencies so changes in `base` cause a rebuild while
- // giving tools like Vite or Parcel a glob that can be used to limit
- // the files that cause a rebuild to only those that match it.
- for (let { base: globBase, pattern } of context.scanner.globs) {
- // Avoid adding a dependency on the base directory itself, since it
- // causes Next.js to start an endless recursion if the `distDir` is
- // configured to anything other than the default `.next` dir.
- if (pattern === '*' && base === globBase) {
- continue
- }
-
- if (pattern === '') {
+ DEBUG && I.start('Scan for candidates')
+ let candidates = compiler.features & Features.Utilities ? context.scanner.scan() : []
+ DEBUG && I.end('Scan for candidates')
+
+ if (compiler.features & Features.Utilities) {
+ DEBUG && I.start('Register dependency messages')
+ // Add all found files as direct dependencies
+ // Note: With Turbopack, the input file might not be a resolved path
+ let resolvedInputFile = path.resolve(base, inputFile)
+ for (let file of context.scanner.files) {
+ let absolutePath = path.resolve(file)
+ // The CSS file cannot be a dependency of itself
+ if (absolutePath === resolvedInputFile) {
+ continue
+ }
result.messages.push({
type: 'dependency',
plugin: '@tailwindcss/postcss',
- file: path.resolve(globBase),
- parent: result.opts.from,
- })
- } else {
- result.messages.push({
- type: 'dir-dependency',
- plugin: '@tailwindcss/postcss',
- dir: path.resolve(globBase),
- glob: pattern,
+ file: absolutePath,
parent: result.opts.from,
})
}
+
+ // Register dependencies so changes in `base` cause a rebuild while
+ // giving tools like Vite or Parcel a glob that can be used to limit
+ // the files that cause a rebuild to only those that match it.
+ for (let { base: globBase, pattern } of context.scanner.globs) {
+ // Avoid adding a dependency on the base directory itself, since it
+ // causes Next.js to start an endless recursion if the `distDir` is
+ // configured to anything other than the default `.next` dir.
+ if (pattern === '*' && base === globBase) {
+ continue
+ }
+
+ if (pattern === '') {
+ result.messages.push({
+ type: 'dependency',
+ plugin: '@tailwindcss/postcss',
+ file: path.resolve(globBase),
+ parent: result.opts.from,
+ })
+ } else {
+ result.messages.push({
+ type: 'dir-dependency',
+ plugin: '@tailwindcss/postcss',
+ dir: path.resolve(globBase),
+ glob: pattern,
+ parent: result.opts.from,
+ })
+ }
+ }
+ DEBUG && I.end('Register dependency messages')
}
- DEBUG && I.end('Register dependency messages')
- }
- DEBUG && I.start('Build utilities')
- let tailwindCssAst = compiler.build(candidates)
- DEBUG && I.end('Build utilities')
+ DEBUG && I.start('Build utilities')
+ let tailwindCssAst = compiler.build(candidates)
+ DEBUG && I.end('Build utilities')
- if (context.tailwindCssAst !== tailwindCssAst) {
- if (optimize) {
- DEBUG && I.start('Optimization')
+ if (context.tailwindCssAst !== tailwindCssAst) {
+ if (optimize) {
+ DEBUG && I.start('Optimization')
- DEBUG && I.start('AST -> CSS')
- let css = toCss(tailwindCssAst)
- DEBUG && I.end('AST -> CSS')
+ DEBUG && I.start('AST -> CSS')
+ let css = toCss(tailwindCssAst)
+ DEBUG && I.end('AST -> CSS')
- DEBUG && I.start('Lightning CSS')
- let ast = optimizeCss(css, {
- minify: typeof optimize === 'object' ? optimize.minify : true,
- })
- DEBUG && I.end('Lightning CSS')
-
- DEBUG && I.start('CSS -> PostCSS AST')
- context.optimizedPostCssAst = postcss.parse(ast, result.opts)
- DEBUG && I.end('CSS -> PostCSS AST')
-
- DEBUG && I.end('Optimization')
- } else {
- // Convert our AST to a PostCSS AST
- DEBUG && I.start('Transform Tailwind CSS AST into PostCSS AST')
- context.cachedPostCssAst = cssAstToPostCssAst(tailwindCssAst, root.source)
- DEBUG && I.end('Transform Tailwind CSS AST into PostCSS AST')
+ DEBUG && I.start('Lightning CSS')
+ let ast = optimizeCss(css, {
+ minify: typeof optimize === 'object' ? optimize.minify : true,
+ })
+ DEBUG && I.end('Lightning CSS')
+
+ DEBUG && I.start('CSS -> PostCSS AST')
+ context.optimizedPostCssAst = postcss.parse(ast, result.opts)
+ DEBUG && I.end('CSS -> PostCSS AST')
+
+ DEBUG && I.end('Optimization')
+ } else {
+ // Convert our AST to a PostCSS AST
+ DEBUG && I.start('Transform Tailwind CSS AST into PostCSS AST')
+ context.cachedPostCssAst = cssAstToPostCssAst(tailwindCssAst, root.source)
+ DEBUG && I.end('Transform Tailwind CSS AST into PostCSS AST')
+ }
}
- }
- context.tailwindCssAst = tailwindCssAst
+ context.tailwindCssAst = tailwindCssAst
+
+ DEBUG && I.start('Update PostCSS AST')
+ root.removeAll()
+ root.append(
+ optimize
+ ? context.optimizedPostCssAst.clone().nodes
+ : context.cachedPostCssAst.clone().nodes,
+ )
+
+ // Trick PostCSS into thinking the indent is 2 spaces, so it uses that
+ // as the default instead of 4.
+ root.raws.indent = ' '
+ DEBUG && I.end('Update PostCSS AST')
- DEBUG && I.start('Update PostCSS AST')
- root.removeAll()
- root.append(
- optimize
- ? context.optimizedPostCssAst.clone().nodes
- : context.cachedPostCssAst.clone().nodes,
- )
+ DEBUG && I.end(`[@tailwindcss/postcss] ${relative(base, inputFile)}`)
+ } catch (error) {
+ // An error requires a full rebuild to fix
+ context.compiler = null
- // Trick PostCSS into thinking the indent is 2 spaces, so it uses that
- // as the default instead of 4.
- root.raws.indent = ' '
- DEBUG && I.end('Update PostCSS AST')
+ // Ensure all dependencies we have collected thus far are included so that the rebuild
+ // is correctly triggered
+ for (let file of context.fullRebuildPaths) {
+ result.messages.push({
+ type: 'dependency',
+ plugin: '@tailwindcss/postcss',
+ file: path.resolve(file),
+ parent: result.opts.from,
+ })
+ }
- DEBUG && I.end(`[@tailwindcss/postcss] ${relative(base, inputFile)}`)
+ // We found that throwing the error will cause PostCSS to no longer watch for changes
+ // in some situations so we instead log the error and continue with an empty stylesheet.
+ console.error(error)
+ root.removeAll()
+ }
},
},
],
diff --git a/packages/tailwindcss/src/compat/config.test.ts b/packages/tailwindcss/src/compat/config.test.ts
index 6bfccb1e3bc1..9e6c3b58a389 100644
--- a/packages/tailwindcss/src/compat/config.test.ts
+++ b/packages/tailwindcss/src/compat/config.test.ts
@@ -1217,7 +1217,7 @@ test('utilities used in @apply must be prefixed', async () => {
`)
// Non-prefixed utilities cause an error
- expect(() =>
+ await expect(
compile(
css`
@config "./config.js";
@@ -1405,7 +1405,7 @@ test('blocklisted candidates are not generated', async () => {
})
test('blocklisted candidates cannot be used with `@apply`', async () => {
- await expect(() =>
+ await expect(
compile(
css`
@theme reference {
diff --git a/packages/tailwindcss/src/index.test.ts b/packages/tailwindcss/src/index.test.ts
index 411e3fe7b426..88ea541a13b0 100644
--- a/packages/tailwindcss/src/index.test.ts
+++ b/packages/tailwindcss/src/index.test.ts
@@ -454,9 +454,9 @@ describe('@apply', () => {
`)
})
- it('should error when using @apply with a utility that does not exist', () => {
- return expect(
- compileCss(css`
+ it('should error when using @apply with a utility that does not exist', async () => {
+ await expect(
+ compile(css`
@tailwind utilities;
.foo {
@@ -468,9 +468,9 @@ describe('@apply', () => {
)
})
- it('should error when using @apply with a variant that does not exist', () => {
- return expect(
- compileCss(css`
+ it('should error when using @apply with a variant that does not exist', async () => {
+ await expect(
+ compile(css`
@tailwind utilities;
.foo {
diff --git a/packages/tailwindcss/src/prefix.test.ts b/packages/tailwindcss/src/prefix.test.ts
index 65e234276994..9556c34e3213 100644
--- a/packages/tailwindcss/src/prefix.test.ts
+++ b/packages/tailwindcss/src/prefix.test.ts
@@ -80,7 +80,7 @@ test('utilities used in @apply must be prefixed', async () => {
`)
// Non-prefixed utilities cause an error
- expect(() =>
+ await expect(
compile(css`
@theme reference prefix(tw);