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')