Skip to content

Fix slow incremental builds (especially on Windows) #17511

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 10 commits into from
Apr 3, 2025
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
12 changes: 12 additions & 0 deletions crates/oxide/src/scanner/detect_sources.rs
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
use crate::scanner::auto_source_detection::IGNORED_CONTENT_DIRS;
use crate::GlobEntry;
use fxhash::FxHashSet;
use globwalk::DirEntry;
Expand Down Expand Up @@ -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;
}
Expand Down
25 changes: 18 additions & 7 deletions crates/oxide/src/scanner/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -93,7 +93,7 @@ pub struct Scanner {
dirs: Vec<PathBuf>,

/// All generated globs, used for setting up watchers
globs: Vec<GlobEntry>,
globs: Option<Vec<GlobEntry>>,

/// Track unique set of candidates
candidates: FxHashSet<String>,
Expand Down Expand Up @@ -296,16 +296,24 @@ impl Scanner {

#[tracing::instrument(skip_all)]
pub fn get_globs(&mut self) -> Vec<GlobEntry> {
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(),
});
Expand All @@ -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<GlobEntry> {
pub fn get_normalized_sources(&self) -> Vec<GlobEntry> {
self.sources
.iter()
.filter_map(|source| match source {
Expand Down
130 changes: 129 additions & 1 deletion integrations/postcss/next.test.ts
Original file line number Diff line number Diff line change
@@ -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',
Expand Down Expand Up @@ -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 (
<html>
<body>{children}</body>
</html>
)
}
`,
'app/page.js': js`
export default function Page() {
return <div className="flex"></div>
}
`,
'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 <div className="flex underline"></div>
}
`,
)
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')
},
)
9 changes: 9 additions & 0 deletions integrations/utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<void>
onStderr: (predicate: (message: string) => boolean) => Promise<void>
}
Expand Down Expand Up @@ -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<void>((resolve) => {
stdoutActors.push({ predicate, resolve })
Expand Down Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion integrations/webpack/index.test.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import { css, html, js, json, test } from '../utils'

test(
'Webpack + PostCSS (watch)',
'webpack + PostCSS (watch)',
{
fs: {
'package.json': json`
Expand Down
Loading