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);