Deleting files should not crash Vite HMR (#20259)

This PR fixes an issue where Vite crashes when a file is deleted.

This is because we don't cleanup files/folders in the scanner when they
are being deleted. This results in an issue where we would use Vite's
`addWatchFile` API with deleted files.

This PR fixes that by making sure that when we re-scan the filesystem
that we clear out files/folders (and recompute globs).

Note: `candidates` themselve are still append-only until a fresh
compiler instance is created since the TypeScript based compiler relies
on that behavior.

Fixes: #17532 
Closes: #20111

## Test plan

1. Added an integration test based on the issue
2. Tested it directly in the reproduction repo

Before deleting:
<img width="988" height="1376" alt="image"
src="https://github.com/user-attachments/assets/0f104ff3-0f88-415a-8311-1cf54ab75d21"
/>


Before:
<img width="1389" height="1376"
alt="file-1b446568bfad50e6a8105d7be33f15f0"
src="https://github.com/user-attachments/assets/d2fbf03e-ca22-4bc4-9bb4-f3de10b90b99"
/>
Which results in:
<img width="988" height="1376" alt="image"
src="https://github.com/user-attachments/assets/8320d61c-4210-4aa8-aa8d-98c5327e9604"
/>

After:
<img width="1389" height="1376"
alt="file-c36ab0265856da5bb7c7a1e77427b821"
src="https://github.com/user-attachments/assets/2ec4c4ea-aa79-4a51-900a-e247dcdb976c"
/>
Which results in:
<img width="988" height="1376" alt="image"
src="https://github.com/user-attachments/assets/8e8dec0d-40eb-4e54-b5fd-8d22abd55973"
/>


[ci-all]
This commit is contained in:
Robin Malfait 2026-06-18 14:29:04 +02:00 • committed by GitHub
parent 5e9f66e428
commit 34c2b04a35
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
4 changed files with 273 additions and 40 deletions

View file

@ -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

View file

@ -373,24 +373,28 @@ impl Scanner {
let mut css_files: Vec<PathBuf> = vec![];
let mut content_paths: Vec<(PathBuf, String)> = Vec::new();
let mut seen_files: FxHashSet<PathBuf> = 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<Vec<u8>> = content_paths
.into_par_iter()

View file

@ -71,6 +71,41 @@ mod scanner {
}
}
fn scanned_files(scanner: &mut Scanner, base: &Path) -> Vec<String> {
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::<Vec<_>>();
files.sort();
files
}
fn scanned_globs(scanner: &mut Scanner, base: &Path) -> Vec<String> {
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::<Vec<_>>();
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::<Vec<_>>();
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::<Vec<_>>();
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 {

View file

@ -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`
<body>
<div id="app" class="underline"></div>
<script type="module" src="./src/main.js"></script>
</body>
`,
'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`
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1 1">
<rect class="fill-red-500" width="1" height="1" />
</svg>
`,
},
},
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)
})
},
)