diff --git a/.github/workflows/integration-tests.yml b/.github/workflows/integration-tests.yml
index 457c38424..fd3c1a361 100644
--- a/.github/workflows/integration-tests.yml
+++ b/.github/workflows/integration-tests.yml
@@ -37,6 +37,7 @@ jobs:
- postcss
- oxide
- webpack
+ - turbopack
# Exclude windows and macos from being built on feature branches
run-all:
diff --git a/CHANGELOG.md b/CHANGELOG.md
index c56103cd8..9044a9c33 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -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
diff --git a/integrations/turbopack/loader.test.ts b/integrations/turbopack/loader.test.ts
index 755cd60df..c5f4fec81 100644
--- a/integrations/turbopack/loader.test.ts
+++ b/integrations/turbopack/loader.test.ts
@@ -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 (
+
+
{children}
+
+ )
+ }
+ `,
+ 'app/page.js': jsx`
+ export default function Page() {
+ return Hello, Next.js!
+ }
+ `,
+ '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}`)
+ })
+ }
+ },
+)
diff --git a/integrations/webpack/loader.test.ts b/integrations/webpack/loader.test.ts
index 9c3d870af..2bd115ab3 100644
--- a/integrations/webpack/loader.test.ts
+++ b/integrations/webpack/loader.test.ts
@@ -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`
+
+ `,
+ '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'])
+ },
+)
diff --git a/packages/@tailwindcss-turbopack/src/index.ts b/packages/@tailwindcss-turbopack/src/index.ts
index a70f28871..2320cb574 100644
--- a/packages/@tailwindcss-turbopack/src/index.ts
+++ b/packages/@tailwindcss-turbopack/src/index.ts
@@ -32,6 +32,7 @@ export interface LoaderOptions {
interface CacheEntry {
mtimes: Map
+ inputCss: string
compiler: null | Awaited>
scanner: null | Scanner
candidates: Set
@@ -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(),
+ inputCss: '',
compiler: null,
scanner: null,
candidates: new Set(),
@@ -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')
diff --git a/packages/@tailwindcss-webpack/src/index.ts b/packages/@tailwindcss-webpack/src/index.ts
index 6d02f1f0b..c714230a9 100644
--- a/packages/@tailwindcss-webpack/src/index.ts
+++ b/packages/@tailwindcss-webpack/src/index.ts
@@ -32,6 +32,7 @@ export interface LoaderOptions {
interface CacheEntry {
mtimes: Map
+ inputCss: string
compiler: null | Awaited>
scanner: null | Scanner
candidates: Set
@@ -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(),
+ inputCss: '',
compiler: null,
scanner: null,
candidates: new Set(),
@@ -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')