tailwindcss/packages/@tailwindcss-postcss/src/index.test.ts
Lazizbek Ergashev 9b0e8af258
Ensure @tailwindcss/postcss rebuilds when the input CSS changes but its mtime is unchanged (#20310)
## Summary

`@tailwindcss/postcss` chooses between an incremental and a full rebuild
by comparing the mtimes of the entry file and its resolved
`@import`/`@config`/`@plugin` graph. It never looks at the input CSS
itself, so when that CSS is produced by an upstream tool (e.g. Sass) and
passed to the plugin via `process()`, it can change while the `from`
file's mtime stays the same. The plugin then re-emits its previously
cached output and silently drops the change.

This stores the input CSS per cache entry and takes the existing full
rebuild path when it differs from the previous compile, mirroring the
fix the CLI watcher already has for changed input files.

Fixes #20307

## Test plan

- Added a regression test in
`packages/@tailwindcss-postcss/src/index.test.ts` that compiles two
different inputs for the same on-disk `from` file (unchanged mtime) and
asserts the second compile reflects the new CSS.
- Confirmed it fails on `main` (the second compile returns the stale
first output) and passes with the fix.
- Ran the `@tailwindcss/postcss` package tests (all green) and checked
formatting with Prettier.

---------

Co-authored-by: Robin Malfait <malfait.robin@gmail.com>
2026-07-06 11:32:20 +00:00

518 lines
13 KiB
TypeScript

import dedent from 'dedent'
import { mkdir, mkdtemp, readFile, rm, unlink, writeFile } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import path from 'path'
import postcss from 'postcss'
import { afterEach, beforeEach, describe, expect, test, vi } from 'vitest'
import { pretty } from '../../tailwindcss/src/test-utils/run'
import tailwindcss from './index'
// We give this file path to PostCSS for processing.
// This file doesn't exist, but the path is used to resolve imports.
// We place it in packages/ because Vitest runs in the monorepo root,
// and packages/tailwindcss must be a sub-folder for
// @import 'tailwindcss' to work.
function inputCssFilePath() {
// Including the current test name to ensure that the cache is invalidated per
// test otherwise the cache will be used across tests.
return `${__dirname}/fixtures/example-project/input.css?test=${expect.getState().currentTestName}`
}
const css = dedent
async function run(plugin: any, from: string, input: string): Promise<string> {
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 } }),
])
let result = await processor.process(`@import 'tailwindcss'`, { from: inputCssFilePath() })
expect(pretty(result.css)).toMatchSnapshot()
// Check for dependency messages
expect(result.messages).toContainEqual({
type: 'dependency',
file: expect.stringMatching(/index.html$/g),
parent: expect.any(String),
plugin: expect.any(String),
})
expect(result.messages).toContainEqual({
type: 'dependency',
file: expect.stringMatching(/index.js$/g),
parent: expect.any(String),
plugin: expect.any(String),
})
expect(result.messages).toContainEqual({
type: 'dir-dependency',
dir: expect.stringMatching(/example-project[/|\\]src$/g),
glob: expect.stringMatching(/^\*\*\/\*/g),
parent: expect.any(String),
plugin: expect.any(String),
})
})
test('output is optimized by Lightning CSS', async () => {
let processor = postcss([
tailwindcss({ base: `${__dirname}/fixtures/example-project`, optimize: { minify: false } }),
])
let result = await processor.process(
css`
@layer utilities {
.foo {
@apply text-[black];
}
}
@layer utilities {
.bar {
color: red;
}
}
`,
{ from: inputCssFilePath() },
)
expect(pretty(result.css)).toMatchInlineSnapshot(`
"
@layer utilities {
.foo {
color: #000;
}
.bar {
color: red;
}
}
"
`)
})
test('@apply can be used without emitting the theme in the CSS file', async () => {
let processor = postcss([
tailwindcss({ base: `${__dirname}/fixtures/example-project`, optimize: { minify: false } }),
])
let result = await processor.process(
css`
@reference 'tailwindcss/theme.css';
.foo {
@apply text-red-500;
}
`,
{ from: inputCssFilePath() },
)
expect(pretty(result.css)).toMatchInlineSnapshot(`
"
.foo {
color: var(--color-red-500, oklch(63.7% .237 25.331));
}
"
`)
})
describe('processing without specifying a base path', () => {
let filepath: string
let dir: string
beforeEach(async () => {
dir = await mkdtemp(path.join(tmpdir(), 'tw-postcss'))
await mkdir(dir, { recursive: true })
filepath = path.join(dir, 'my-test-file.html')
await writeFile(filepath, `<div class="md:[&:hover]:content-['testing_default_base_path']">`)
})
afterEach(() => unlink(filepath))
test('the current working directory is used by default', async () => {
using spy = vi.spyOn(process, 'cwd')
spy.mockReturnValue(dir)
let processor = postcss([tailwindcss({ optimize: { minify: false } })])
let result = await processor.process(`@import "tailwindcss"`, { from: inputCssFilePath() })
expect(result.css).toContain(
".md\\:\\[\\&\\:hover\\]\\:content-\\[\\'testing_default_base_path\\'\\]",
)
expect(result.messages).toContainEqual({
type: 'dependency',
file: expect.stringMatching(/my-test-file.html$/g),
parent: expect.any(String),
plugin: expect.any(String),
})
})
})
test('fallback to `base` directory when `result.opts.from` is not provided', async () => {
using _ = vi.spyOn(console, 'warn').mockImplementation(() => {})
let processor = postcss([
tailwindcss({ base: `${__dirname}/fixtures/example-project`, optimize: { minify: false } }),
])
let result = await processor.process(`@import 'tailwindcss'`)
expect(result.css.length).toBeGreaterThan(0)
})
describe('plugins', () => {
test('local CJS plugin', async () => {
let processor = postcss([
tailwindcss({ base: `${__dirname}/fixtures/example-project`, optimize: { minify: false } }),
])
let result = await processor.process(
css`
@import 'tailwindcss/utilities';
@plugin './plugin.js';
`,
{ from: inputCssFilePath() },
)
expect(pretty(result.css)).toMatchInlineSnapshot(`
"
.underline {
text-decoration-line: underline;
}
@media (inverted-colors: inverted) {
.inverted\\:flex {
display: flex;
}
}
.hocus\\:underline:focus, .hocus\\:underline:hover {
text-decoration-line: underline;
}
"
`)
})
test('local CJS plugin from `@import`-ed file', async () => {
let processor = postcss([
tailwindcss({ base: `${__dirname}/fixtures/example-project`, optimize: { minify: false } }),
])
let result = await processor.process(
css`
@import 'tailwindcss/utilities';
@import '../example-project/src/relative-import.css';
`,
{ from: `${__dirname}/fixtures/another-project/input.css` },
)
expect(pretty(result.css)).toMatchInlineSnapshot(`
"
.underline {
text-decoration-line: underline;
}
@media (inverted-colors: inverted) {
.inverted\\:flex {
display: flex;
}
}
.hocus\\:underline:focus, .hocus\\:underline:hover {
text-decoration-line: underline;
}
"
`)
})
test('published CJS plugin', async () => {
let processor = postcss([
tailwindcss({ base: `${__dirname}/fixtures/example-project`, optimize: { minify: false } }),
])
let result = await processor.process(
css`
@import 'tailwindcss/utilities';
@plugin 'internal-example-plugin';
`,
{ from: inputCssFilePath() },
)
expect(pretty(result.css)).toMatchInlineSnapshot(`
"
.underline {
text-decoration-line: underline;
}
@media (inverted-colors: inverted) {
.inverted\\:flex {
display: flex;
}
}
.hocus\\:underline:focus, .hocus\\:underline:hover {
text-decoration-line: underline;
}
"
`)
})
})
test('bail early when Tailwind is not used', async () => {
let processor = postcss([
tailwindcss({ base: `${__dirname}/fixtures/example-project`, optimize: { minify: false } }),
])
let result = await processor.process(
css`
.custom-css {
color: red;
}
`,
{ from: inputCssFilePath() },
)
// `fixtures/example-project` includes an `underline` candidate. But since we
// didn't use `@tailwind utilities` we didn't scan for utilities.
expect(result.css).not.toContain('.underline {')
expect(pretty(result.css)).toMatchInlineSnapshot(`
"
.custom-css {
color: red;
}
"
`)
})
test('handle CSS when only using a `@reference` (we should not bail early)', async () => {
let processor = postcss([
tailwindcss({ base: `${__dirname}/fixtures/example-project`, optimize: { minify: false } }),
])
let result = await processor.process(
css`
@reference "tailwindcss/theme.css";
.foo {
@variant md {
bar: baz;
}
}
`,
{ from: inputCssFilePath() },
)
expect(pretty(result.css)).toMatchInlineSnapshot(`
"
@media (min-width: 48rem) {
.foo {
bar: baz;
}
}
"
`)
})
test('handle CSS when using a `@variant` using variants that do not rely on the `@theme`', async () => {
let processor = postcss([
tailwindcss({ base: `${__dirname}/fixtures/example-project`, optimize: { minify: false } }),
])
let result = await processor.process(
css`
.foo {
@variant data-is-hoverable {
bar: baz;
}
}
`,
{ from: inputCssFilePath() },
)
expect(pretty(result.css)).toMatchInlineSnapshot(`
"
.foo[data-is-hoverable] {
bar: baz;
}
"
`)
})
test('runs `Once` plugins in the right order', async () => {
let before = ''
let after = ''
let processor = postcss([
{
postcssPlugin: 'before',
Once(root) {
before = root.toString()
},
},
tailwindcss({ base: `${__dirname}/fixtures/example-project`, optimize: { minify: false } }),
{
postcssPlugin: 'after',
Once(root) {
after = root.toString()
},
},
])
let result = await processor.process(
css`
@theme {
--color-red-500: red;
}
.custom-css {
color: theme(--color-red-500);
}
`,
{ from: inputCssFilePath() },
)
expect(pretty(result.css)).toMatchInlineSnapshot(`
"
.custom-css {
color: red;
}
"
`)
expect(pretty(before)).toMatchInlineSnapshot(`
"
@theme {
--color-red-500: red;
}
.custom-css {
color: theme(--color-red-500);
}
"
`)
expect(pretty(after)).toMatchInlineSnapshot(`
"
.custom-css {
color: red;
}
"
`)
})
describe('concurrent builds', () => {
let dir: string
beforeEach(async () => {
dir = await mkdtemp(path.join(tmpdir(), 'tw-postcss'))
await writeFile(path.join(dir, 'index.html'), `<div class="underline"></div>`)
await writeFile(
path.join(dir, 'index.css'),
css`
@import './dependency.css';
`,
)
await writeFile(
path.join(dir, 'dependency.css'),
css`
@tailwind utilities;
`,
)
})
afterEach(() => rm(dir, { recursive: true, force: true }))
test('does experience a race-condition when calling the plugin two times for the same change', async () => {
using spy = vi.spyOn(process, 'cwd')
spy.mockReturnValue(dir)
let from = path.join(dir, 'index.css')
let input = (await readFile(path.join(dir, 'index.css'))).toString()
let plugin = tailwindcss({ optimize: { minify: false } })
let result = await run(plugin, from, input)
expect(result).toContain('.underline')
// Ensure that the mtime is updated
await new Promise((resolve) => setTimeout(resolve, 100))
await writeFile(
path.join(dir, 'dependency.css'),
css`
@tailwind utilities;
.red {
color: red;
}
`,
)
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 } }),
])
let result = await processor.process(`@tailwind utilities`, { from: './input.css' })
expect(pretty(result.css)).toMatchInlineSnapshot(`
"
.underline {
text-decoration-line: underline;
}
"
`)
// Check for dependency messages
expect(result.messages).not.toContainEqual({
type: 'dependency',
file: expect.stringMatching(/input.css$/g),
parent: expect.any(String),
plugin: expect.any(String),
})
})