Skip to content
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 @@ -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

Expand Down
36 changes: 18 additions & 18 deletions crates/oxide/src/scanner/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Comment on lines +378 to +381

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm not super happy about this, but I'll likely refactor this file in the near future. For now, this should do the job.


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) => {
Expand All @@ -403,26 +407,22 @@ impl Scanner {
true
};

if !changed {
continue;
}
Comment on lines +410 to +412

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Small optimization: bailing early so we don't have to compute extension.as_str() and the logic below since we already know nothing changed and would no-op.


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));
Comment on lines +423 to +424

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Not clearing this out entirely, because if we later re-scan a file that wasn't changed then there is no need to re-parse and re-compute the candidates since the candidates are append-only which would result in more work and no actual new candidates.


// Read + preprocess all discovered files in parallel
let scanned_blobs: Vec<Vec<u8>> = content_paths
.into_par_iter()
Expand Down
179 changes: 157 additions & 22 deletions crates/oxide/tests/scanner.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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>,
Expand All @@ -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
Expand Down Expand Up @@ -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 {
Expand Down
97 changes: 97 additions & 0 deletions integrations/vite/index.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Comment thread
greptile-apps[bot] marked this conversation as resolved.
`,
)

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