Skip to content

Commit f7f47f8

Browse files
authored
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 #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 (#17554), and it compares the input CSS with the input CSS of the last build since #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>
1 parent d580998 commit f7f47f8

6 files changed

Lines changed: 217 additions & 0 deletions

File tree

‎.github/workflows/integration-tests.yml‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,7 @@ jobs:
3737
- postcss
3838
- oxide
3939
- webpack
40+
- turbopack
4041

4142
# Exclude windows and macos from being built on feature branches
4243
run-all:

‎CHANGELOG.md‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -54,6 +54,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
5454
- 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))
5555
- 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))
5656
- 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))
57+
- 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))
5758

5859
## [4.3.3] - 2026-07-16
5960

‎integrations/turbopack/loader.test.ts‎

Lines changed: 96 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -81,3 +81,99 @@ test(
8181
})
8282
},
8383
)
84+
85+
// https://github.com/tailwindlabs/tailwindcss/issues/20581
86+
test(
87+
'@tailwindcss/turbopack loader picks up changes to the CSS file (dev)',
88+
{
89+
timeout: 120_000,
90+
fs: {
91+
'package.json': json`
92+
{
93+
"dependencies": {
94+
"next": "^16.2.7",
95+
"react": "^19.2.7",
96+
"react-dom": "^19.2.7",
97+
"tailwindcss": "workspace:^",
98+
"@tailwindcss/turbopack": "workspace:^"
99+
}
100+
}
101+
`,
102+
'next.config.mjs': js`
103+
export default {
104+
turbopack: {
105+
rules: {
106+
'*.css': {
107+
loaders: ['@tailwindcss/turbopack'],
108+
as: '*.css',
109+
},
110+
},
111+
},
112+
}
113+
`,
114+
'app/layout.js': jsx`
115+
import './globals.css'
116+
117+
export default function RootLayout({ children }) {
118+
return (
119+
<html>
120+
<body>{children}</body>
121+
</html>
122+
)
123+
}
124+
`,
125+
'app/page.js': jsx`
126+
export default function Page() {
127+
return <div className="flex">Hello, Next.js!</div>
128+
}
129+
`,
130+
'app/globals.css': css`
131+
@import 'tailwindcss';
132+
133+
.custom-1 {
134+
color: red;
135+
}
136+
`,
137+
},
138+
},
139+
async ({ fs, spawn, expect }) => {
140+
let process = await spawn('pnpm next dev')
141+
142+
let url = ''
143+
await process.onStdout((message) => {
144+
let match = /Local:\s*(http.*)/.exec(message)
145+
if (match) url = match[1]
146+
return Boolean(url)
147+
})
148+
149+
await process.onStdout((message) => message.includes('Ready in'))
150+
151+
await retryAssertion(async () => {
152+
let styles = await fetchStyles(url)
153+
expect(styles).toContain(candidate`flex`)
154+
expect(styles).toContain(candidate`custom-1`)
155+
})
156+
157+
// Turbopack can run the loader with the previous version of the CSS file
158+
// first, which must not leave the served CSS one edit behind
159+
for (let i = 2; i <= 4; i++) {
160+
await fs.write(
161+
'app/globals.css',
162+
css`
163+
@import 'tailwindcss';
164+
165+
.custom-${i} {
166+
color: red;
167+
}
168+
`,
169+
)
170+
171+
await retryAssertion(async () => {
172+
let styles = await fetchStyles(url)
173+
expect(styles).toContain(candidate`flex`)
174+
expect(styles).toContain(candidate`custom-${i}`)
175+
expect(styles).not.toContain(candidate`custom-${i - 1}`)
176+
})
177+
}
178+
},
179+
)

‎integrations/webpack/loader.test.ts‎

Lines changed: 93 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -493,3 +493,96 @@ test(
493493
await fs.expectFileNotToContain('dist/b.css', ['only-a', '--color-red-500'])
494494
},
495495
)
496+
497+
test(
498+
'@tailwindcss/webpack loader rebuilds when a loader before it changes the CSS (watch)',
499+
{
500+
fs: {
501+
'package.json': json`
502+
{
503+
"main": "./src/index.js",
504+
"browser": "./src/index.js",
505+
"dependencies": {
506+
"css-loader": "^6",
507+
"webpack": "^5",
508+
"webpack-cli": "^5",
509+
"mini-css-extract-plugin": "^2",
510+
"tailwindcss": "workspace:^",
511+
"@tailwindcss/webpack": "workspace:^"
512+
}
513+
}
514+
`,
515+
'webpack.config.js': js`
516+
let MiniCssExtractPlugin = require('mini-css-extract-plugin')
517+
let path = require('node:path')
518+
519+
module.exports = {
520+
output: {
521+
clean: true,
522+
},
523+
plugins: [new MiniCssExtractPlugin()],
524+
module: {
525+
rules: [
526+
{
527+
test: /.css$/i,
528+
use: [
529+
MiniCssExtractPlugin.loader,
530+
'css-loader',
531+
'@tailwindcss/webpack',
532+
path.resolve(__dirname, 'partial-loader.js'),
533+
],
534+
},
535+
],
536+
},
537+
}
538+
`,
539+
// Inlines a partial like Sass does, so the CSS changes while the input
540+
// file on disk doesn't
541+
'partial-loader.js': js`
542+
let fs = require('node:fs')
543+
let path = require('node:path')
544+
545+
module.exports = function (source) {
546+
let partial = path.resolve(__dirname, 'src/partial.css')
547+
this.addDependency(partial)
548+
return source + fs.readFileSync(partial, 'utf8')
549+
}
550+
`,
551+
'src/index.js': js`import './index.css'`,
552+
'src/index.html': html`
553+
<div class="flex"></div>
554+
`,
555+
'src/index.css': css`
556+
@import 'tailwindcss/theme';
557+
@import 'tailwindcss/utilities';
558+
`,
559+
'src/partial.css': css`
560+
.partial-1 {
561+
color: red;
562+
}
563+
`,
564+
},
565+
},
566+
async ({ fs, spawn, exec }) => {
567+
// Generate the initial build so output CSS files exist on disk
568+
await exec('pnpm webpack --mode=development')
569+
570+
let process = await spawn('pnpm webpack --mode=development --watch')
571+
await process.onStdout((m) => m.includes('compiled successfully in'))
572+
573+
await fs.expectFileToContain('dist/main.css', ['.flex', '.partial-1'])
574+
575+
await fs.write(
576+
'src/partial.css',
577+
css`
578+
.partial-2 {
579+
color: red;
580+
}
581+
`,
582+
)
583+
await process.onStdout((m) => m.includes('compiled successfully in'))
584+
585+
await fs.expectFileToContain('dist/main.css', ['.flex', '.partial-2'])
586+
await fs.expectFileNotToContain('dist/main.css', ['.partial-1'])
587+
},
588+
)

‎packages/@tailwindcss-turbopack/src/index.ts‎

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,7 @@ export interface LoaderOptions {
3232

3333
interface CacheEntry {
3434
mtimes: Map<string, number>
35+
inputCss: string
3536
compiler: null | Awaited<ReturnType<typeof compile>>
3637
scanner: null | Scanner
3738
candidates: Set<string>
@@ -49,6 +50,7 @@ function getContextFromCache(resourceId: string, opts: LoaderOptions): CacheEntr
4950
if (cache.has(key)) return cache.get(key)!
5051
let entry: CacheEntry = {
5152
mtimes: new Map<string, number>(),
53+
inputCss: '',
5254
compiler: null,
5355
scanner: null,
5456
candidates: new Set<string>(),
@@ -162,6 +164,17 @@ export default async function tailwindLoader(
162164
rebuildStrategy = 'full'
163165
context.mtimes.set(file, changedTime)
164166
}
167+
168+
// The mtimes above only track files on disk, but the source we receive can
169+
// differ from what's on disk: Turbopack can run the loader with the
170+
// previous version of the input file after its mtime already changed, and
171+
// loaders that run before this one (e.g. Sass) can change the source
172+
// without changing any tracked file. So also rebuild when the source
173+
// differs from the last build.
174+
if (context.inputCss !== source) {
175+
rebuildStrategy = 'full'
176+
context.inputCss = source
177+
}
165178
}
166179
DEBUG && I.end('Register full rebuild paths')
167180

‎packages/@tailwindcss-webpack/src/index.ts‎

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,7 @@ export interface LoaderOptions {
3232

3333
interface CacheEntry {
3434
mtimes: Map<string, number>
35+
inputCss: string
3536
compiler: null | Awaited<ReturnType<typeof compile>>
3637
scanner: null | Scanner
3738
candidates: Set<string>
@@ -49,6 +50,7 @@ function getContextFromCache(resourceId: string, opts: LoaderOptions): CacheEntr
4950
if (cache.has(key)) return cache.get(key)!
5051
let entry: CacheEntry = {
5152
mtimes: new Map<string, number>(),
53+
inputCss: '',
5254
compiler: null,
5355
scanner: null,
5456
candidates: new Set<string>(),
@@ -162,6 +164,17 @@ export default async function tailwindLoader(
162164
rebuildStrategy = 'full'
163165
context.mtimes.set(file, changedTime)
164166
}
167+
168+
// The mtimes above only track files on disk, but the source we receive can
169+
// differ from what's on disk: Turbopack can run the loader with the
170+
// previous version of the input file after its mtime already changed, and
171+
// loaders that run before this one (e.g. Sass) can change the source
172+
// without changing any tracked file. So also rebuild when the source
173+
// differs from the last build.
174+
if (context.inputCss !== source) {
175+
rebuildStrategy = 'full'
176+
context.inputCss = source
177+
}
165178
}
166179
DEBUG && I.end('Register full rebuild paths')
167180

0 commit comments

Comments
 (0)