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:
parent
5e9f66e428
commit
34c2b04a35
4 changed files with 273 additions and 40 deletions
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
})
|
||||
},
|
||||
)
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue