From 70ccac2fcdf1305565dec71be7c215824deca6c9 Mon Sep 17 00:00:00 2001 From: lazerg Date: Sun, 5 Jul 2026 18:58:40 +0500 Subject: [PATCH 1/6] Ensure `@tailwindcss/postcss` rebuilds when the input CSS changes but its mtime is unchanged --- CHANGELOG.md | 1 + .../@tailwindcss-postcss/src/index.test.ts | 39 +++++++++++++++++++ packages/@tailwindcss-postcss/src/index.ts | 12 ++++++ 3 files changed, 52 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index d1e7cdf28db6..ea165fd358e3 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)) +- Ensure `@tailwindcss/postcss` recompiles when the input CSS changes even if the `from` file's mtime is unchanged, such as when the CSS is generated by a preprocessor like Sass ([#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..989408068319 100644 --- a/packages/@tailwindcss-postcss/src/index.test.ts +++ b/packages/@tailwindcss-postcss/src/index.test.ts @@ -456,6 +456,45 @@ describe('concurrent builds', () => { }) }) +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 } }) + + 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 first = await run(css` + @tailwind utilities; + .first { + @apply underline; + } + `) + let second = await run(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..7e52fa25d059 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 + cachedInputCss: 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(), + cachedInputCss: '', 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.toString() + if (context.cachedInputCss !== inputCss) { + rebuildStrategy = 'full' + context.cachedInputCss = inputCss + } } DEBUG && I.end('Register full rebuild paths') From 055dff26c955b833687c78f5798f5c11f10ed495 Mon Sep 17 00:00:00 2001 From: lazerg Date: Mon, 6 Jul 2026 04:58:22 +0500 Subject: [PATCH 2/6] Avoid re-serializing CSS on every incremental rebuild --- packages/@tailwindcss-postcss/src/index.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/@tailwindcss-postcss/src/index.ts b/packages/@tailwindcss-postcss/src/index.ts index 7e52fa25d059..ce733f0a7651 100644 --- a/packages/@tailwindcss-postcss/src/index.ts +++ b/packages/@tailwindcss-postcss/src/index.ts @@ -201,7 +201,7 @@ function tailwindcss(opts: PluginOptions = {}): AcceptedPlugin { // 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.toString() + let inputCss = root.source?.input.css ?? root.toString() if (context.cachedInputCss !== inputCss) { rebuildStrategy = 'full' context.cachedInputCss = inputCss From 1490f5c3b6791209d79edeb949bcf44b5c0408ce Mon Sep 17 00:00:00 2001 From: lazerg Date: Mon, 6 Jul 2026 04:58:26 +0500 Subject: [PATCH 3/6] Extract shared runOnce helper in postcss plugin tests --- .../@tailwindcss-postcss/src/index.test.ts | 66 +++++++++---------- 1 file changed, 32 insertions(+), 34 deletions(-) diff --git a/packages/@tailwindcss-postcss/src/index.test.ts b/packages/@tailwindcss-postcss/src/index.test.ts index 989408068319..2cb60a89071a 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 runOnce(plugin: any, from: string, 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() +} + 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 runOnce(plugin, from, input) expect(result).toContain('.underline') @@ -448,8 +448,8 @@ describe('concurrent builds', () => { `, ) - let promise1 = run(input) - let promise2 = run(input) + let promise1 = runOnce(plugin, from, input) + let promise2 = runOnce(plugin, from, input) expect(await promise1).toContain('.red') expect(await promise2).toContain('.red') @@ -466,28 +466,26 @@ test('rebuilds when the input CSS changes even if the `from` file on disk did no let from = path.join(dir, 'index.css') let plugin = tailwindcss({ base: dir, 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: [] } }) + let first = await runOnce( + plugin, + from, + css` + @tailwind utilities; + .first { + @apply underline; } - } - return ast.toString() - } - - let first = await run(css` - @tailwind utilities; - .first { - @apply underline; - } - `) - let second = await run(css` - @tailwind utilities; - .second { - @apply line-through; - } - `) + `, + ) + let second = await runOnce( + plugin, + from, + css` + @tailwind utilities; + .second { + @apply line-through; + } + `, + ) expect(first).toContain('.first') expect(second).toContain('.second') From 3f42191cb48e84bb1d8248d2f9a252760d35b4c6 Mon Sep 17 00:00:00 2001 From: Robin Malfait Date: Mon, 6 Jul 2026 13:20:31 +0200 Subject: [PATCH 4/6] rename `runOnce` to `run` --- packages/@tailwindcss-postcss/src/index.test.ts | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/packages/@tailwindcss-postcss/src/index.test.ts b/packages/@tailwindcss-postcss/src/index.test.ts index 2cb60a89071a..b393cc382ec9 100644 --- a/packages/@tailwindcss-postcss/src/index.test.ts +++ b/packages/@tailwindcss-postcss/src/index.test.ts @@ -20,9 +20,9 @@ function inputCssFilePath() { const css = dedent -async function runOnce(plugin: any, from: string, input: string): Promise { +async function run(plugin: any, from: string, input: string): Promise { let ast = postcss.parse(input) - for (let runner of (plugin as any).plugins) { + for (let runner of plugin.plugins) { if (runner.Once) { await runner.Once(ast, { postcss, result: { opts: { from }, messages: [] } }) } @@ -432,7 +432,7 @@ describe('concurrent builds', () => { let plugin = tailwindcss({ optimize: { minify: false } }) - let result = await runOnce(plugin, from, input) + let result = await run(plugin, from, input) expect(result).toContain('.underline') @@ -448,8 +448,8 @@ describe('concurrent builds', () => { `, ) - let promise1 = runOnce(plugin, from, input) - let promise2 = runOnce(plugin, from, input) + let promise1 = run(plugin, from, input) + let promise2 = run(plugin, from, input) expect(await promise1).toContain('.red') expect(await promise2).toContain('.red') @@ -466,7 +466,7 @@ test('rebuilds when the input CSS changes even if the `from` file on disk did no let from = path.join(dir, 'index.css') let plugin = tailwindcss({ base: dir, optimize: { minify: false } }) - let first = await runOnce( + let first = await run( plugin, from, css` @@ -476,7 +476,7 @@ test('rebuilds when the input CSS changes even if the `from` file on disk did no } `, ) - let second = await runOnce( + let second = await run( plugin, from, css` From 9e40caafae009dba36ef26c6024f1809058bafa9 Mon Sep 17 00:00:00 2001 From: Robin Malfait Date: Mon, 6 Jul 2026 13:22:47 +0200 Subject: [PATCH 5/6] update changelog --- CHANGELOG.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index ea165fd358e3..f20f7cd14fc9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -15,7 +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)) -- Ensure `@tailwindcss/postcss` recompiles when the input CSS changes even if the `from` file's mtime is unchanged, such as when the CSS is generated by a preprocessor like Sass ([#20310](https://github.com/tailwindlabs/tailwindcss/pull/20310)) +- 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 From 1849cf948f0fbf7e39146b94dac843978e84b884 Mon Sep 17 00:00:00 2001 From: Robin Malfait Date: Mon, 6 Jul 2026 13:26:08 +0200 Subject: [PATCH 6/6] rename `cachedInputCss` to `inputCss` This information is already scoped to a `CacheEntry` --- packages/@tailwindcss-postcss/src/index.ts | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/packages/@tailwindcss-postcss/src/index.ts b/packages/@tailwindcss-postcss/src/index.ts index ce733f0a7651..a4a8d93268ae 100644 --- a/packages/@tailwindcss-postcss/src/index.ts +++ b/packages/@tailwindcss-postcss/src/index.ts @@ -20,7 +20,7 @@ const DEBUG = env.DEBUG interface CacheEntry { mtimes: Map - cachedInputCss: string + inputCss: string compiler: null | ReturnType scanner: null | Scanner tailwindCssAst: AstNode[] @@ -35,7 +35,7 @@ function getContextFromCache(postcss: Postcss, inputFile: string, opts: PluginOp if (cache.has(key)) return cache.get(key)! let entry = { mtimes: new Map(), - cachedInputCss: '', + inputCss: '', compiler: null, scanner: null, @@ -202,9 +202,9 @@ function tailwindcss(opts: PluginOptions = {}): AcceptedPlugin { // 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.cachedInputCss !== inputCss) { + if (context.inputCss !== inputCss) { rebuildStrategy = 'full' - context.cachedInputCss = inputCss + context.inputCss = inputCss } } DEBUG && I.end('Register full rebuild paths')