diff --git a/CHANGELOG.md b/CHANGELOG.md index c84cf0858242..2fda841e197f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -17,6 +17,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Ensure `@tailwindcss/vite` doesn't crash in Deno v2.8.x when `context.parentURL` is not a valid URL ([#20245](https://github.com/tailwindlabs/tailwindcss/pull/20245)) - Ensure `@tailwindcss/cli` in `--watch` mode rebuilds when the input CSS file changes in an ignored directory ([#20246](https://github.com/tailwindlabs/tailwindcss/pull/20246)) - Ensure `@variant` rules generated by `addBase` can use custom variants defined later ([#20247](https://github.com/tailwindlabs/tailwindcss/pull/20247)) +- Ensure `@tailwindcss/vite` doesn't crash during HMR when scanned files or directories are deleted ([#20259](https://github.com/tailwindlabs/tailwindcss/pull/20259)) ## [4.3.1] - 2026-06-12 diff --git a/crates/oxide/src/scanner/mod.rs b/crates/oxide/src/scanner/mod.rs index 30f8c792f71e..d665b2f4daa3 100644 --- a/crates/oxide/src/scanner/mod.rs +++ b/crates/oxide/src/scanner/mod.rs @@ -373,24 +373,28 @@ impl Scanner { let mut css_files: Vec = vec![]; let mut content_paths: Vec<(PathBuf, String)> = Vec::new(); - let mut seen_files: FxHashSet = FxHashSet::default(); + + // Fresh state + self.files.clear(); + self.dirs.clear(); + self.extensions.clear(); + self.globs = None; for (path, is_dir, extension) in all_entries { if is_dir { self.dirs.insert(path); } else { // Deduplicate: parallel walk can visit the same file from multiple threads - if !seen_files.insert(path.clone()) { + if !self.files.insert(path.clone()) { continue; } + self.extensions.insert(extension.clone()); // On re-scans, check mtime to skip unchanged files. // On the first scan we skip this entirely to avoid extra // metadata syscalls. let changed = if self.has_scanned_once { - let current_mtime = std::fs::metadata(&path) - .ok() - .and_then(|m| m.modified().ok()); + let current_mtime = path.metadata().ok().and_then(|m| m.modified().ok()); match current_mtime { Some(mtime) => { @@ -403,26 +407,22 @@ impl Scanner { true }; + if !changed { + continue; + } + match extension.as_str() { // Special handing for CSS files, we don't want to extract candidates from // these files, but we do want to extract used CSS variables. - "css" => { - if changed { - css_files.push(path.clone()); - } - } - _ => { - if changed { - content_paths.push((path.clone(), extension.clone())); - } - } + "css" => css_files.push(path), + _ => content_paths.push((path, extension)), } - - self.extensions.insert(extension); - self.files.insert(path); } } + // Ensure `mtimes` don't include stale files + self.mtimes.retain(|path, _| self.files.contains(path)); + // Read + preprocess all discovered files in parallel let scanned_blobs: Vec> = content_paths .into_par_iter() diff --git a/crates/oxide/tests/scanner.rs b/crates/oxide/tests/scanner.rs index 135b2bda52de..827ac50b8d42 100644 --- a/crates/oxide/tests/scanner.rs +++ b/crates/oxide/tests/scanner.rs @@ -71,6 +71,41 @@ mod scanner { } } + fn scanned_files(scanner: &mut Scanner, base: &Path) -> Vec { + let base_dir = + format!("{}{}", dunce::canonicalize(base).unwrap().display(), "/").replace('\\', "/"); + + let mut files = scanner + .get_files() + .iter() + // Normalize paths to use unix style separators + .map(|file| file.replace('\\', "/").replace(&base_dir, "")) + .collect::>(); + files.sort(); + files + } + + fn scanned_globs(scanner: &mut Scanner, base: &Path) -> Vec { + let base_dir = + format!("{}{}", dunce::canonicalize(base).unwrap().display(), "/").replace('\\', "/"); + + let mut globs = scanner + .get_globs() + .iter() + .map(|glob| { + if glob.pattern.starts_with('/') { + format!("{}{}", glob.base, glob.pattern) + } else { + format!("{}/{}", glob.base, glob.pattern) + } + }) + // Normalize paths to use unix style separators + .map(|file| file.replace('\\', "/").replace(&base_dir, "")) + .collect::>(); + globs.sort(); + globs + } + fn scan_with_globs( paths_with_content: &[(&str, &str)], source_directives: Vec<&str>, @@ -95,34 +130,14 @@ mod scanner { let mut scanner = Scanner::new(sources); let candidates = scanner.scan(); - let base_dir = format!("{}{}", dunce::canonicalize(&base).unwrap().display(), "/").replace('\\', "/"); // Get all scanned files as strings relative to the base directory - let mut files = scanner - .get_files() - .iter() - // Normalize paths to use unix style separators - .map(|file| file.replace('\\', "/").replace(&base_dir, "")) - .collect::>(); - files.sort(); + let files = scanned_files(&mut scanner, Path::new(&base)); // Get all scanned globs as strings relative to the base directory - let mut globs = scanner - .get_globs() - .iter() - .map(|glob| { - if glob.pattern.starts_with('/') { - format!("{}{}", glob.base, glob.pattern) - } else { - format!("{}/{}", glob.base, glob.pattern) - } - }) - // Normalize paths to use unix style separators - .map(|file| file.replace('\\', "/").replace(&base_dir, "")) - .collect::>(); - globs.sort(); + let globs = scanned_globs(&mut scanner, Path::new(&base)); // Get all normalized sources as strings relative to the base directory let mut normalized_sources = scanner @@ -910,6 +925,126 @@ mod scanner { ); } + #[test] + fn it_should_remove_deleted_files_from_scanned_files() { + // Create a temporary working directory + let dir = tempdir().unwrap().into_path(); + + // Initialize this directory as a git repository + let _ = Command::new("git").arg("init").current_dir(&dir).output(); + + // Create files + create_files_in( + &dir, + &[ + ("src/index.html", "content-['src/index.html']"), + ("src/keep.html", "content-['src/keep.html']"), + ("src/remove.html", "content-['src/remove.html']"), + ], + ); + + let mut scanner = Scanner::new(vec![public_source_entry_from_pattern( + dir.clone(), + "@source '**/*'", + )]); + + scanner.scan(); + assert_eq!( + scanned_files(&mut scanner, &dir), + vec!["src/index.html", "src/keep.html", "src/remove.html"] + ); + + fs::remove_file(dir.join("src/remove.html")).unwrap(); + + scanner.scan(); + assert_eq!( + scanned_files(&mut scanner, &dir), + vec!["src/index.html", "src/keep.html"] + ); + } + + #[test] + fn it_should_remove_deleted_directories_from_scanned_files() { + // Create a temporary working directory + let dir = tempdir().unwrap().into_path(); + + // Initialize this directory as a git repository + let _ = Command::new("git").arg("init").current_dir(&dir).output(); + + // Create files + create_files_in( + &dir, + &[ + ("src/index.html", "content-['src/index.html']"), + ("src/keep/index.html", "content-['src/keep/index.html']"), + ("src/remove/index.html", "content-['src/remove/index.html']"), + ( + "src/remove/nested/index.html", + "content-['src/remove/nested/index.html']", + ), + ], + ); + + let mut scanner = Scanner::new(vec![public_source_entry_from_pattern( + dir.clone(), + "@source '**/*'", + )]); + + scanner.scan(); + assert_eq!( + scanned_files(&mut scanner, &dir), + vec![ + "src/index.html", + "src/keep/index.html", + "src/remove/index.html", + "src/remove/nested/index.html", + ] + ); + + fs::remove_dir_all(dir.join("src/remove")).unwrap(); + + scanner.scan(); + assert_eq!( + scanned_files(&mut scanner, &dir), + vec!["src/index.html", "src/keep/index.html"] + ); + } + + #[test] + fn it_should_remove_deleted_directories_from_scanned_globs() { + // Create a temporary working directory + let dir = tempdir().unwrap().into_path(); + + // Initialize this directory as a git repository + let _ = Command::new("git").arg("init").current_dir(&dir).output(); + + // Create files + create_files_in( + &dir, + &[ + ("index.html", "content-['index.html']"), + ("src/index.html", "content-['src/index.html']"), + ], + ); + + let mut scanner = Scanner::new(vec![public_source_entry_from_pattern( + dir.clone(), + "@source '**/*'", + )]); + + scanner.scan(); + + let globs = scanned_globs(&mut scanner, &dir); + assert!(globs.iter().any(|glob| glob.starts_with("src/**/*"))); + + fs::remove_dir_all(dir.join("src")).unwrap(); + + scanner.scan(); + + let globs = scanned_globs(&mut scanner, &dir); + assert!(!globs.iter().any(|glob| glob.starts_with("src/**/*"))); + } + #[test] fn it_should_ignore_negated_custom_sources() { let ScanResult { diff --git a/integrations/vite/index.test.ts b/integrations/vite/index.test.ts index 4c936a2a23af..f6633ff4991e 100644 --- a/integrations/vite/index.test.ts +++ b/integrations/vite/index.test.ts @@ -1275,3 +1275,100 @@ test( await fs.expectFileToContain(filename, [candidate`content-['index.html']`]) }, ) + +// https://github.com/tailwindlabs/tailwindcss/issues/17532 +test( + 'deleting a file should not crash Vite', + { + fs: { + 'package.json': json` + { + "type": "module", + "dependencies": { + "@tailwindcss/vite": "workspace:^", + "tailwindcss": "workspace:^" + }, + "devDependencies": { + "vite": "6.2.5" + } + } + `, + 'vite.config.ts': ts` + import tailwindcss from '@tailwindcss/vite' + import { defineConfig } from 'vite' + + export default defineConfig({ + plugins: [tailwindcss()], + }) + `, + 'index.html': html` + +
+ + + `, + 'src/index.css': css`@import 'tailwindcss';`, + 'src/main.js': js` + import iconUrl from './assets/icon.svg?url' + import './index.css' + + document.querySelector('#app').innerHTML = iconUrl + `, + 'src/assets/icon.svg': html` + + + + `, + }, + }, + async ({ fs, spawn, expect }) => { + let process = await spawn('pnpm vite dev') + await process.onStdout((m) => m.includes('ready in')) + + let url = '' + await process.onStdout((m) => { + let match = /Local:\s*(http.*)\//.exec(m) + if (match) url = match[1] + return Boolean(url) + }) + + await retryAssertion(async () => { + let [main, css] = await Promise.all([ + fetch(`${url}/src/main.js`).then((r) => r.text()), + fetch(`${url}/src/index.css`).then((r) => r.text()), + ]) + + expect(main).toContain('/src/assets/icon.svg?import&url') + expect(main).toContain('/src/index.css') + expect(css).toContain('.fill-red-500') + }) + + process.flush() + + let unexpectedError = process.onStderr((m) => { + return /error|crash|panic/i.test(m) + }) + + await fs.write( + 'src/main.js', + js` + import './index.css' + document.querySelector('#app').innerHTML = 'Hello World' + `, + ) + + await fs.delete('src/assets/icon.svg') + + let result = await Promise.race([ + unexpectedError.then(() => 'stderr'), + new Promise((resolve) => setTimeout(() => resolve('timeout'), 500)), + ]) + + expect(result).toBe('timeout') + + await retryAssertion(async () => { + let response = await fetch(`${url}/src/index.css?t=${Date.now()}`) + expect(response.status).toBe(200) + }) + }, +)