Fix race condition in Next.js with --turbopack (#17514)

This PR fixes an issue where if you use Next.js with `--turbopack` a
race condition happens because the `@tailwindcss/postcss` plugin is
called twice in rapid succession.

The first call sees an update and does a partial update with the new
classes. Next some internal `mtimes` are updated. The second call
therefore doesn't see any changes anymore because the `mtimes` are the
same, therefore it's serving its stale data.

Fixes: #17508

## Test plan

- Tested with the repro provided in #17508
- Added a new unit test that calls into the PostCSS plugin directly for
the same change from the same JavaScript run-loop.

---------

Co-authored-by: Philipp Spiess <hello@philippspiess.com>
This commit is contained in:
Robin Malfait 2025-04-03 17:07:38 +02:00 committed by GitHub
parent e45302b910
commit 81a676f129
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
4 changed files with 82 additions and 15 deletions

View File

@ -13,6 +13,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- Fix multi-value inset shadow ([#17523](https://github.com/tailwindlabs/tailwindcss/pull/17523))
- Fix `drop-shadow` utility ([#17515](https://github.com/tailwindlabs/tailwindcss/pull/17515))
- Fix `drop-shadow-*` utilities that use multiple shadows in `@theme inline` ([#17515](https://github.com/tailwindlabs/tailwindcss/pull/17515))
- PostCSS: Fix race condition when two changes are queued concurrently ([#17514](https://github.com/tailwindlabs/tailwindcss/pull/17514))
- PostCSS: Ensure we process files containing an `@tailwind utilities;` directive ([#17514](https://github.com/tailwindlabs/tailwindcss/pull/17514))
## [4.1.1] - 2025-04-02

View File

@ -589,6 +589,8 @@ export async function fetchStyles(base: string, path = '/'): Promise<string> {
async function gracefullyRemove(dir: string) {
// Skip removing the directory in CI because it can stall on Windows
if (!process.env.CI) {
await fs.rm(dir, { recursive: true, force: true })
await fs.rm(dir, { recursive: true, force: true }).catch((error) => {
console.log(`Failed to remove ${dir}`, error)
})
}
}

View File

@ -1,5 +1,5 @@
import dedent from 'dedent'
import { mkdir, mkdtemp, unlink, writeFile } from 'node:fs/promises'
import { mkdir, mkdtemp, readFile, rm, unlink, writeFile } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import path from 'path'
import postcss from 'postcss'
@ -357,3 +357,64 @@ test('runs `Once` plugins in the right order', async () => {
}"
`)
})
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('the current working directory is used by default', async () => {
const 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 } })
async function run(input: string): Promise<string> {
let ast = postcss.parse(input)
for (let runner of (plugin as any).plugins) {
if (runner.Once) {
await runner.Once(ast, { result: { opts: { from }, messages: [] } })
}
}
return ast.toString()
}
let result = await run(input)
expect(result).toContain('.underline')
await writeFile(
path.join(dir, 'dependency.css'),
css`
@tailwind utilities;
.red {
color: red;
}
`,
)
let promise1 = run(input)
let promise2 = run(input)
expect(await promise1).toContain('.red')
expect(await promise2).toContain('.red')
})
})

View File

@ -20,7 +20,7 @@ const DEBUG = env.DEBUG
interface CacheEntry {
mtimes: Map<string, number>
compiler: null | Awaited<ReturnType<typeof compileAst>>
compiler: null | ReturnType<typeof compileAst>
scanner: null | Scanner
tailwindCssAst: AstNode[]
cachedPostCssAst: postcss.Root
@ -89,7 +89,8 @@ function tailwindcss(opts: PluginOptions = {}): AcceptedPlugin {
node.name === 'variant' ||
node.name === 'config' ||
node.name === 'plugin' ||
node.name === 'apply'
node.name === 'apply' ||
node.name === 'tailwind'
) {
canBail = false
return false
@ -138,9 +139,9 @@ function tailwindcss(opts: PluginOptions = {}): AcceptedPlugin {
// Setup the compiler if it doesn't exist yet. This way we can
// guarantee a `build()` function is available.
context.compiler ??= await createCompiler()
context.compiler ??= createCompiler()
if (context.compiler.features === Features.None) {
if ((await context.compiler).features === Features.None) {
return
}
@ -188,25 +189,27 @@ function tailwindcss(opts: PluginOptions = {}): AcceptedPlugin {
// initial build. If it wasn't, we need to create a new one.
!isInitialBuild
) {
context.compiler = await createCompiler()
context.compiler = createCompiler()
}
let compiler = await context.compiler
if (context.scanner === null || rebuildStrategy === 'full') {
DEBUG && I.start('Setup scanner')
let sources = (() => {
// Disable auto source detection
if (context.compiler.root === 'none') {
if (compiler.root === 'none') {
return []
}
// No root specified, use the base directory
if (context.compiler.root === null) {
if (compiler.root === null) {
return [{ base, pattern: '**/*', negated: false }]
}
// Use the specified root
return [{ ...context.compiler.root, negated: false }]
})().concat(context.compiler.sources)
return [{ ...compiler.root, negated: false }]
})().concat(compiler.sources)
// Look for candidates used to generate the CSS
context.scanner = new Scanner({ sources })
@ -214,11 +217,10 @@ function tailwindcss(opts: PluginOptions = {}): AcceptedPlugin {
}
DEBUG && I.start('Scan for candidates')
let candidates =
context.compiler.features & Features.Utilities ? context.scanner.scan() : []
let candidates = compiler.features & Features.Utilities ? context.scanner.scan() : []
DEBUG && I.end('Scan for candidates')
if (context.compiler.features & Features.Utilities) {
if (compiler.features & Features.Utilities) {
DEBUG && I.start('Register dependency messages')
// Add all found files as direct dependencies
for (let file of context.scanner.files) {
@ -267,7 +269,7 @@ function tailwindcss(opts: PluginOptions = {}): AcceptedPlugin {
}
DEBUG && I.start('Build utilities')
let tailwindCssAst = context.compiler.build(candidates)
let tailwindCssAst = compiler.build(candidates)
DEBUG && I.end('Build utilities')
if (context.tailwindCssAst !== tailwindCssAst) {