diff --git a/CHANGELOG.md b/CHANGELOG.md index a14843895943..2fa0a0e7937b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -16,6 +16,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - 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)) - Ensure the `color-mix(…)` polyfill creates fallbacks even when using colors that can not be statically analyzed ([#17513](https://github.com/tailwindlabs/tailwindcss/pull/17513)) +- Fix slow incremental builds with `@tailwindcss/vite` and `@tailwindcss/postscss` (especially on Windows) ([#17511](https://github.com/tailwindlabs/tailwindcss/pull/17511)) ## [4.1.1] - 2025-04-02 diff --git a/crates/oxide/src/scanner/detect_sources.rs b/crates/oxide/src/scanner/detect_sources.rs index 3a32038ddedd..9dc340fbaf47 100644 --- a/crates/oxide/src/scanner/detect_sources.rs +++ b/crates/oxide/src/scanner/detect_sources.rs @@ -1,3 +1,4 @@ +use crate::scanner::auto_source_detection::IGNORED_CONTENT_DIRS; use crate::GlobEntry; use fxhash::FxHashSet; use globwalk::DirEntry; @@ -101,6 +102,17 @@ pub fn resolve_globs( continue; } + if IGNORED_CONTENT_DIRS + .iter() + .any(|dir| match path.file_name() { + Some(name) => name == *dir, + None => false, + }) + { + it.skip_current_dir(); + continue; + } + if !allowed_paths.contains(path) { continue; } diff --git a/crates/oxide/src/scanner/mod.rs b/crates/oxide/src/scanner/mod.rs index 612f5a8648e3..fad6fa712da4 100644 --- a/crates/oxide/src/scanner/mod.rs +++ b/crates/oxide/src/scanner/mod.rs @@ -93,7 +93,7 @@ pub struct Scanner { dirs: Vec, /// All generated globs, used for setting up watchers - globs: Vec, + globs: Option>, /// Track unique set of candidates candidates: FxHashSet, @@ -296,16 +296,24 @@ impl Scanner { #[tracing::instrument(skip_all)] pub fn get_globs(&mut self) -> Vec { + if let Some(globs) = &self.globs { + return globs.clone(); + } + self.scan_sources(); + let mut globs = vec![]; for source in self.sources.iter() { match source { SourceEntry::Auto { base } | SourceEntry::External { base } => { - let globs = resolve_globs((base).to_path_buf(), &self.dirs, &self.extensions); - self.globs.extend(globs); + globs.extend(resolve_globs( + base.to_path_buf(), + &self.dirs, + &self.extensions, + )); } SourceEntry::Pattern { base, pattern } => { - self.globs.push(GlobEntry { + globs.push(GlobEntry { base: base.to_string_lossy().to_string(), pattern: pattern.to_string(), }); @@ -315,13 +323,16 @@ impl Scanner { } // Re-optimize the globs to reduce the number of patterns we have to scan. - self.globs = optimize_patterns(&self.globs); + globs = optimize_patterns(&globs); + + // Track the globs for subsequent calls + self.globs = Some(globs.clone()); - self.globs.clone() + globs } #[tracing::instrument(skip_all)] - pub fn get_normalized_sources(&mut self) -> Vec { + pub fn get_normalized_sources(&self) -> Vec { self.sources .iter() .filter_map(|source| match source { diff --git a/integrations/postcss/next.test.ts b/integrations/postcss/next.test.ts index dbc31ffe19a1..969254a75c8c 100644 --- a/integrations/postcss/next.test.ts +++ b/integrations/postcss/next.test.ts @@ -1,5 +1,5 @@ import { describe } from 'vitest' -import { candidate, css, fetchStyles, js, json, retryAssertion, test } from '../utils' +import { candidate, css, fetchStyles, js, json, jsx, retryAssertion, test, txt } from '../utils' test( 'production build', @@ -356,3 +356,131 @@ test( }) }, ) + +test( + 'changes to `public/` should not trigger an infinite loop', + { + fs: { + 'package.json': json` + { + "dependencies": { + "react": "^18", + "react-dom": "^18", + "next": "^15", + "@ducanh2912/next-pwa": "^10.2.9" + }, + "devDependencies": { + "@tailwindcss/postcss": "workspace:^", + "tailwindcss": "workspace:^" + } + } + `, + '.gitignore': txt` + .next/ + public/workbox-*.js + public/sw.js + `, + 'postcss.config.mjs': js` + export default { + plugins: { + '@tailwindcss/postcss': {}, + }, + } + `, + 'next.config.mjs': js` + import withPWA from '@ducanh2912/next-pwa' + + const pwaConfig = { + dest: 'public', + register: true, + skipWaiting: true, + reloadOnOnline: false, + cleanupOutdatedCaches: true, + clientsClaim: true, + maximumFileSizeToCacheInBytes: 20 * 1024 * 1024, + } + + const nextConfig = {} + + const configWithPWA = withPWA(pwaConfig)(nextConfig) + + export default configWithPWA + `, + 'app/layout.js': js` + import './globals.css' + + export default function RootLayout({ children }) { + return ( + + {children} + + ) + } + `, + 'app/page.js': js` + export default function Page() { + return
+ } + `, + 'app/globals.css': css` + @import 'tailwindcss/theme'; + @import 'tailwindcss/utilities'; + `, + }, + }, + async ({ spawn, fs, expect }) => { + let process = await spawn('pnpm next dev') + + let url = '' + await process.onStdout((m) => { + let match = /Local:\s*(http.*)/.exec(m) + if (match) url = match[1] + return Boolean(url) + }) + + await process.onStdout((m) => m.includes('Ready in')) + + await retryAssertion(async () => { + let css = await fetchStyles(url) + expect(css).toContain(candidate`flex`) + expect(css).not.toContain(candidate`underline`) + }) + + await fs.write( + 'app/page.js', + jsx` + export default function Page() { + return
+ } + `, + ) + await process.onStdout((m) => m.includes('Compiled in')) + + await retryAssertion(async () => { + let css = await fetchStyles(url) + expect(css).toContain(candidate`flex`) + expect(css).toContain(candidate`underline`) + }) + // Flush all existing messages in the queue + process.flush() + + // Fetch the styles one more time, to ensure we see the latest version of + // the CSS + await fetchStyles(url) + + // At this point, no changes should triger a compile step. If we see any + // changes, there is an infinite loop because we (the user) didn't write any + // files to disk. + // + // Ensure there are no more changes in stdout (signaling no infinite loop) + let result = await Promise.race([ + // If this succeeds, it means that it saw another change which indicates + // an infinite loop. + process.onStdout((m) => m.includes('Compiled in')).then(() => 'infinite loop detected'), + + // There should be no changes in stdout + new Promise((resolve) => setTimeout(() => resolve('no infinite loop detected'), 2_000)), + ]) + expect(result).toBe('no infinite loop detected') + }, +) diff --git a/integrations/utils.ts b/integrations/utils.ts index 82f88c690bf0..a1165b1da6f7 100644 --- a/integrations/utils.ts +++ b/integrations/utils.ts @@ -15,6 +15,7 @@ const PUBLIC_PACKAGES = (await fs.readdir(path.join(REPO_ROOT, 'dist'))).map((na interface SpawnedProcess { dispose: () => void + flush: () => void onStdout: (predicate: (message: string) => boolean) => Promise onStderr: (predicate: (message: string) => boolean) => Promise } @@ -253,6 +254,13 @@ export function test( return { dispose, + flush() { + stdoutActors.splice(0) + stderrActors.splice(0) + + stdoutMessages.splice(0) + stderrMessages.splice(0) + }, onStdout(predicate: (message: string) => boolean) { return new Promise((resolve) => { stdoutActors.push({ predicate, resolve }) @@ -504,6 +512,7 @@ export let css = dedent export let html = dedent export let ts = dedent export let js = dedent +export let jsx = dedent export let json = dedent export let yaml = dedent export let txt = dedent diff --git a/integrations/webpack/index.test.ts b/integrations/webpack/index.test.ts index 8a4c871a78e1..2a91411d2494 100644 --- a/integrations/webpack/index.test.ts +++ b/integrations/webpack/index.test.ts @@ -1,7 +1,7 @@ import { css, html, js, json, test } from '../utils' test( - 'Webpack + PostCSS (watch)', + 'webpack + PostCSS (watch)', { fs: { 'package.json': json`