diff --git a/CHANGELOG.md b/CHANGELOG.md index d1e7cdf28db6..f20f7cd14fc9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -15,6 +15,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Prevent `theme('colors.foo')` in JS plugins from returning an internal disambiguation object when a CSS theme key shares a prefix with a sibling key like `--color-foo-bar` ([#20299](https://github.com/tailwindlabs/tailwindcss/pull/20299)) - Ensure fractional opacity modifiers work with named shadow sizes like `shadow-sm/12.5`, `text-shadow-sm/12.5`, `drop-shadow-sm/12.5`, and `inset-shadow-sm/12.5` ([#20302](https://github.com/tailwindlabs/tailwindcss/pull/20302)) - Fix parsing selectors like `[data-foo]div` as one selector instead of two ([#20303](https://github.com/tailwindlabs/tailwindcss/pull/20303)) +- Fix stale `@tailwindcss/postcss` output when changed input CSS is passed from a preprocessor like Sass without updating the input file on disk ([#20310](https://github.com/tailwindlabs/tailwindcss/pull/20310)) ## [4.3.2] - 2026-06-26 diff --git a/packages/@tailwindcss-postcss/src/index.test.ts b/packages/@tailwindcss-postcss/src/index.test.ts index 14acdec500f1..b393cc382ec9 100644 --- a/packages/@tailwindcss-postcss/src/index.test.ts +++ b/packages/@tailwindcss-postcss/src/index.test.ts @@ -20,6 +20,16 @@ function inputCssFilePath() { const css = dedent +async function run(plugin: any, from: string, input: string): Promise { + let ast = postcss.parse(input) + for (let runner of plugin.plugins) { + if (runner.Once) { + await runner.Once(ast, { postcss, result: { opts: { from }, messages: [] } }) + } + } + return ast.toString() +} + test("`@import 'tailwindcss'` is replaced with the generated CSS", async () => { let processor = postcss([ tailwindcss({ base: `${__dirname}/fixtures/example-project`, optimize: { minify: false } }), @@ -422,17 +432,7 @@ describe('concurrent builds', () => { let plugin = tailwindcss({ optimize: { minify: false } }) - async function run(input: string): Promise { - let ast = postcss.parse(input) - for (let runner of (plugin as any).plugins) { - if (runner.Once) { - await runner.Once(ast, { postcss, result: { opts: { from }, messages: [] } }) - } - } - return ast.toString() - } - - let result = await run(input) + let result = await run(plugin, from, input) expect(result).toContain('.underline') @@ -448,14 +448,51 @@ describe('concurrent builds', () => { `, ) - let promise1 = run(input) - let promise2 = run(input) + let promise1 = run(plugin, from, input) + let promise2 = run(plugin, from, input) expect(await promise1).toContain('.red') expect(await promise2).toContain('.red') }) }) +test('rebuilds when the input CSS changes even if the `from` file on disk did not', async () => { + let dir = await mkdtemp(path.join(tmpdir(), 'tw-postcss')) + // The `from` file exists on disk with a stable mtime. The input CSS is handed + // to the plugin directly (as a preprocessor like Sass would produce it), so it + // can change without `from`'s mtime ever changing. + await writeFile(path.join(dir, 'index.css'), '') + + let from = path.join(dir, 'index.css') + let plugin = tailwindcss({ base: dir, optimize: { minify: false } }) + + let first = await run( + plugin, + from, + css` + @tailwind utilities; + .first { + @apply underline; + } + `, + ) + let second = await run( + plugin, + from, + css` + @tailwind utilities; + .second { + @apply line-through; + } + `, + ) + + expect(first).toContain('.first') + expect(second).toContain('.second') + + await rm(dir, { recursive: true, force: true }) +}) + test('does not register the input file as a dependency, even if it is passed in as relative path', async () => { let processor = postcss([ tailwindcss({ base: `${__dirname}/fixtures/example-project`, optimize: { minify: false } }), diff --git a/packages/@tailwindcss-postcss/src/index.ts b/packages/@tailwindcss-postcss/src/index.ts index d0706045a154..a4a8d93268ae 100644 --- a/packages/@tailwindcss-postcss/src/index.ts +++ b/packages/@tailwindcss-postcss/src/index.ts @@ -20,6 +20,7 @@ const DEBUG = env.DEBUG interface CacheEntry { mtimes: Map + inputCss: string compiler: null | ReturnType scanner: null | Scanner tailwindCssAst: AstNode[] @@ -34,6 +35,7 @@ function getContextFromCache(postcss: Postcss, inputFile: string, opts: PluginOp if (cache.has(key)) return cache.get(key)! let entry = { mtimes: new Map(), + inputCss: '', compiler: null, scanner: null, @@ -194,6 +196,16 @@ function tailwindcss(opts: PluginOptions = {}): AcceptedPlugin { rebuildStrategy = 'full' context.mtimes.set(file, changedTime) } + + // The mtimes above only track files on disk. When the input CSS is + // generated by another tool (e.g. Sass) and passed to `process()`, + // it can change without any tracked file's mtime changing, so also + // rebuild when the input CSS itself differs from the last build. + let inputCss = root.source?.input.css ?? root.toString() + if (context.inputCss !== inputCss) { + rebuildStrategy = 'full' + context.inputCss = inputCss + } } DEBUG && I.end('Register full rebuild paths')