Rebuild @tailwindcss/turbopack and @tailwindcss/webpack when the input CSS changes (#20582)

This PR fixes an issue where `next dev` with the
`@tailwindcss/turbopack` loader keeps serving the CSS of the previous
edit after you edit the CSS file. Only a restart of the dev server shows
the latest version.

Fixes https://github.com/tailwindlabs/tailwindcss/issues/20581

The loader caches a compiler for each CSS file. It only does a full
rebuild when the mtime of the input file (or of one of its dependencies)
changes. After a save, Turbopack can run the loader two times:

1. With the previous version of the CSS file. The file on disk already
has the new mtime, so we did a full rebuild from the old source, and
stored the new mtime.
2. With the new version of the CSS file. The mtime did not change since
the first call, so we did an incremental build with the stale compiler
of the first call.

The loader registers the folders that it scans as context dependencies,
and the CSS file is in one of them (e.g. `app/`). So one save
invalidates the loader in two ways, and the first run can still get the
previous content of the file.

`@tailwindcss/postcss` had the same problem with Turbopack
(https://github.com/tailwindlabs/tailwindcss/pull/17554), and it
compares the input CSS with the input CSS of the last build since
https://github.com/tailwindlabs/tailwindcss/pull/20310. The loader now
does the same: it stores the source on the cache entry, and does a full
rebuild when the source changes.

`@tailwindcss/turbopack` is a copy of `@tailwindcss/webpack`, so the
webpack loader gets the same fix. With webpack, the problem shows up
when a loader that runs before ours changes the CSS, but not the input
file. For example, `sass-loader` inlines a partial, and you edit that
partial.

The matrix of the integration tests in CI had no `turbopack` entry, so
the tests in `integrations/turbopack` never ran in CI. This PR adds it.

## Test plan

- Added an integration test to `integrations/turbopack/loader.test.ts`.
It edits `app/globals.css` three times during `next dev`, and checks
that the served CSS has each edit. On `main` (with Next.js 16.4.1), the
served CSS still has `.custom-1` after the first edit. With this change,
it passes.
- Added an integration test to `integrations/webpack/loader.test.ts`,
with a loader before `@tailwindcss/webpack` that inlines
`src/partial.css`. In watch mode, it edits that partial. On `main`, the
output keeps `.partial-1`. With this change, it passes.
- Called the built loaders directly, like Turbopack does: first with the
previous source after the file changed on disk, then with the new
source. On `main`, the second call returns the CSS of the previous
version. With this change, each call returns the CSS of the source that
it gets.
- `pnpm run build` passes, and all tests in
`integrations/turbopack/loader.test.ts` and
`integrations/webpack/loader.test.ts` pass.

<sub>Sent by an AI assistant on behalf of @RobinMalfait.</sub>
This commit is contained in:
Robin Malfait authored and GitHub committed 2026-10-16 13:28:05 +02:00
1 parent d580998627
commit f7f47f8e98
6 files changed
+217

No files matched your search

+1
View File
@@ -37,6 +37,7 @@ jobs:
- postcss
- oxide
- webpack
- turbopack
# Exclude windows and macos from being built on feature branches
run-all:
+1
View File
@@ -54,6 +54,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- Canonicalization: prevent `collapse: true` from taking exponentially longer when classes are linked through a utility that sets many properties (e.g. `sr-only`) ([#20558](https://github.com/tailwindlabs/tailwindcss/pull/20558))
- Ensure `&` is substituted inside `:-moz-any(…)` and `:-webkit-any(…)` in custom variants (e.g. `@custom-variant firefox (:-moz-any(&));`) ([#20486](https://github.com/tailwindlabs/tailwindcss/pull/20486))
- Ensure the standalone macOS binaries have a valid code signature, so macOS 27 no longer kills them at launch ([#20578](https://github.com/tailwindlabs/tailwindcss/pull/20578))
- Ensure `@tailwindcss/turbopack` and `@tailwindcss/webpack` rebuild when the input CSS changes (e.g. `next dev` with Turbopack no longer serves the CSS of the previous edit) ([#20582](https://github.com/tailwindlabs/tailwindcss/pull/20582))
## [4.3.3] - 2026-07-16
+96
View File
@@ -81,3 +81,99 @@ test(
})
},
)
// https://github.com/tailwindlabs/tailwindcss/issues/20581
test(
'@tailwindcss/turbopack loader picks up changes to the CSS file (dev)',
{
timeout: 120_000,
fs: {
'package.json': json`
{
"dependencies": {
"next": "^16.2.7",
"react": "^19.2.7",
"react-dom": "^19.2.7",
"tailwindcss": "workspace:^",
"@tailwindcss/turbopack": "workspace:^"
}
}
`,
'next.config.mjs': js`
export default {
turbopack: {
rules: {
'*.css': {
loaders: ['@tailwindcss/turbopack'],
as: '*.css',
},
},
},
}
`,
'app/layout.js': jsx`
import './globals.css'
export default function RootLayout({ children }) {
return (
<html>
<body>{children}</body>
</html>
)
}
`,
'app/page.js': jsx`
export default function Page() {
return <div className="flex">Hello, Next.js!</div>
}
`,
'app/globals.css': css`
@import 'tailwindcss';
.custom-1 {
color: red;
}
`,
},
},
async ({ fs, spawn, expect }) => {
let process = await spawn('pnpm next dev')
let url = ''
await process.onStdout((message) => {
let match = /Local:\s*(http.*)/.exec(message)
if (match) url = match[1]
return Boolean(url)
})
await process.onStdout((message) => message.includes('Ready in'))
await retryAssertion(async () => {
let styles = await fetchStyles(url)
expect(styles).toContain(candidate`flex`)
expect(styles).toContain(candidate`custom-1`)
})
// Turbopack can run the loader with the previous version of the CSS file
// first, which must not leave the served CSS one edit behind
for (let i = 2; i <= 4; i++) {
await fs.write(
'app/globals.css',
css`
@import 'tailwindcss';
.custom-${i} {
color: red;
}
`,
)
await retryAssertion(async () => {
let styles = await fetchStyles(url)
expect(styles).toContain(candidate`flex`)
expect(styles).toContain(candidate`custom-${i}`)
expect(styles).not.toContain(candidate`custom-${i - 1}`)
})
}
},
)
+93
View File
@@ -493,3 +493,96 @@ test(
await fs.expectFileNotToContain('dist/b.css', ['only-a', '--color-red-500'])
},
)
test(
'@tailwindcss/webpack loader rebuilds when a loader before it changes the CSS (watch)',
{
fs: {
'package.json': json`
{
"main": "./src/index.js",
"browser": "./src/index.js",
"dependencies": {
"css-loader": "^6",
"webpack": "^5",
"webpack-cli": "^5",
"mini-css-extract-plugin": "^2",
"tailwindcss": "workspace:^",
"@tailwindcss/webpack": "workspace:^"
}
}
`,
'webpack.config.js': js`
let MiniCssExtractPlugin = require('mini-css-extract-plugin')
let path = require('node:path')
module.exports = {
output: {
clean: true,
},
plugins: [new MiniCssExtractPlugin()],
module: {
rules: [
{
test: /.css$/i,
use: [
MiniCssExtractPlugin.loader,
'css-loader',
'@tailwindcss/webpack',
path.resolve(__dirname, 'partial-loader.js'),
],
},
],
},
}
`,
// Inlines a partial like Sass does, so the CSS changes while the input
// file on disk doesn't
'partial-loader.js': js`
let fs = require('node:fs')
let path = require('node:path')
module.exports = function (source) {
let partial = path.resolve(__dirname, 'src/partial.css')
this.addDependency(partial)
return source + fs.readFileSync(partial, 'utf8')
}
`,
'src/index.js': js`import './index.css'`,
'src/index.html': html`
<div class="flex"></div>
`,
'src/index.css': css`
@import 'tailwindcss/theme';
@import 'tailwindcss/utilities';
`,
'src/partial.css': css`
.partial-1 {
color: red;
}
`,
},
},
async ({ fs, spawn, exec }) => {
// Generate the initial build so output CSS files exist on disk
await exec('pnpm webpack --mode=development')
let process = await spawn('pnpm webpack --mode=development --watch')
await process.onStdout((m) => m.includes('compiled successfully in'))
await fs.expectFileToContain('dist/main.css', ['.flex', '.partial-1'])
await fs.write(
'src/partial.css',
css`
.partial-2 {
color: red;
}
`,
)
await process.onStdout((m) => m.includes('compiled successfully in'))
await fs.expectFileToContain('dist/main.css', ['.flex', '.partial-2'])
await fs.expectFileNotToContain('dist/main.css', ['.partial-1'])
},
)
@@ -32,6 +32,7 @@ export interface LoaderOptions {
interface CacheEntry {
mtimes: Map<string, number>
inputCss: string
compiler: null | Awaited<ReturnType<typeof compile>>
scanner: null | Scanner
candidates: Set<string>
@@ -49,6 +50,7 @@ function getContextFromCache(resourceId: string, opts: LoaderOptions): CacheEntr
if (cache.has(key)) return cache.get(key)!
let entry: CacheEntry = {
mtimes: new Map<string, number>(),
inputCss: '',
compiler: null,
scanner: null,
candidates: new Set<string>(),
@@ -162,6 +164,17 @@ export default async function tailwindLoader(
rebuildStrategy = 'full'
context.mtimes.set(file, changedTime)
}
// The mtimes above only track files on disk, but the source we receive can
// differ from what's on disk: Turbopack can run the loader with the
// previous version of the input file after its mtime already changed, and
// loaders that run before this one (e.g. Sass) can change the source
// without changing any tracked file. So also rebuild when the source
// differs from the last build.
if (context.inputCss !== source) {
rebuildStrategy = 'full'
context.inputCss = source
}
}
DEBUG && I.end('Register full rebuild paths')
@@ -32,6 +32,7 @@ export interface LoaderOptions {
interface CacheEntry {
mtimes: Map<string, number>
inputCss: string
compiler: null | Awaited<ReturnType<typeof compile>>
scanner: null | Scanner
candidates: Set<string>
@@ -49,6 +50,7 @@ function getContextFromCache(resourceId: string, opts: LoaderOptions): CacheEntr
if (cache.has(key)) return cache.get(key)!
let entry: CacheEntry = {
mtimes: new Map<string, number>(),
inputCss: '',
compiler: null,
scanner: null,
candidates: new Set<string>(),
@@ -162,6 +164,17 @@ export default async function tailwindLoader(
rebuildStrategy = 'full'
context.mtimes.set(file, changedTime)
}
// The mtimes above only track files on disk, but the source we receive can
// differ from what's on disk: Turbopack can run the loader with the
// previous version of the input file after its mtime already changed, and
// loaders that run before this one (e.g. Sass) can change the source
// without changing any tracked file. So also rebuild when the source
// differs from the last build.
if (context.inputCss !== source) {
rebuildStrategy = 'full'
context.inputCss = source
}
}
DEBUG && I.end('Register full rebuild paths')