Skip to content

Commit 6de87c6

Browse files
Fix changes to symlinked files outside of the project works (tailwindlabs#20356)
This PR fixes an issue where changes to a symlinked file wouldn't result in hot-reload when using `@tailwincdss/vite`. This issue also exists in the other packages such as `@tailwincdss/postcss`, `@tailwincdss/webpack` and `@tailwincdss/cli`. The issue is that we watch the symlinked file, but not the "real" file for changes. If the source of the symlinked file lives in another folder that is not covered by auto-source detection or by any of the `@source` directives, then changes to that file won't trigger a change. To solve this, if a file is symlinked or lives in a symlinked folder, then we will make sure that the `scanner.files` contains the real path / canonicalized path to the real file as well just so we can detect changes in that file. Fixes: tailwindlabs#20346 Closes: tailwindlabs#20347 ## Test plan 1. Added a regression test for `@tailwindcss/vite` 2. Added tests in the scanner code itself 3. Manually tested on the reproduction: | &nbsp; | Initial state | After change | | ---: | --- | --- | | **Before** | <img width="3200" height="1800" alt="file-f8616573eec3150ae484fd279204c6d7" src="https://github.com/user-attachments/assets/2734ecc3-b5b6-420e-820d-8a4a8fcdd7f5" /> | <img width="3200" height="1800" alt="file-0e93fd3c2c9694c844b098616a3208d2" src="https://github.com/user-attachments/assets/fd86859b-420b-476b-80f6-e94d02e8b07b" /> | | **After** | <img width="3200" height="1800" alt="file-f8616573eec3150ae484fd279204c6d7" src="https://github.com/user-attachments/assets/2734ecc3-b5b6-420e-820d-8a4a8fcdd7f5" /> | <img width="3200" height="1800" alt="file-eb7b37430ea1363dddeac7226007afe4" src="https://github.com/user-attachments/assets/0a95b1a4-a357-491d-b57f-78460c0fc9db" /> | [ci-all] --------- Co-authored-by: Nic <162764842+Nic-Polumeyv@users.noreply.github.com>
1 parent 4c12866 commit 6de87c6

4 files changed

Lines changed: 359 additions & 65 deletions

File tree

CHANGELOG.md

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
77

88
## [Unreleased]
99

10-
- Nothing yet!
10+
### Fixed
11+
12+
- Ensure watch mode detects changes to symlinked `@source` files whose real paths aren't otherwise scanned ([#20356](https://github.com/tailwindlabs/tailwindcss/pull/20356))
1113

1214
## [4.3.3] - 2026-07-16
1315

crates/oxide/src/scanner/mod.rs

Lines changed: 124 additions & 64 deletions
Original file line numberDiff line numberDiff line change
@@ -181,6 +181,11 @@ impl Scanner {
181181
continue;
182182
}
183183

184+
// The walked path can contain symlinks, while the changed files have already
185+
// been canonicalized. Lazily canonicalize the walked path so we can compare
186+
// the real paths as well.
187+
let mut canonical_path: Option<PathBuf> = None;
188+
184189
let mut drop_file_indexes = vec![];
185190
for (idx, changed_file) in new_unknown_files.iter().enumerate().rev() {
186191
let ChangedContent::File(file, _) = changed_file else {
@@ -189,7 +194,15 @@ impl Scanner {
189194

190195
// When the file is found on disk it means that all the rules pass. We can
191196
// extract the current file and remove it from the list of passed in files.
192-
if file == path {
197+
let matches = file == path
198+
|| (file.file_name() == path.file_name() && {
199+
if canonical_path.is_none() {
200+
canonical_path = dunce::canonicalize(path).ok();
201+
}
202+
canonical_path.as_deref() == Some(file.as_path())
203+
});
204+
205+
if matches {
193206
self.files.insert(path.to_path_buf()); // Track for future use
194207
content_to_scan.push(changed_file.clone()); // Track for parsing
195208
drop_file_indexes.push(idx);
@@ -388,47 +401,93 @@ impl Scanner {
388401
self.extensions.clear();
389402
self.globs = None;
390403

391-
for (path, is_dir, extension, mtime) in all_entries {
392-
if is_dir {
393-
self.dirs.insert(path);
394-
} else {
395-
// Deduplicate: parallel walk can visit the same file from multiple threads
396-
if !self.files.insert(path.clone()) {
397-
continue;
404+
// Cache canonicalized folders in case a file itself is not symlinked, but any of the parent
405+
// folders are symlinked.
406+
let mut cached_canonical_dirs: FxHashMap<PathBuf, PathBuf> = FxHashMap::default();
407+
408+
for entry in all_entries {
409+
match entry {
410+
WalkEntry::Dir(path) => {
411+
self.dirs.insert(path);
398412
}
399-
self.extensions.insert(extension.clone());
400-
401-
// On incremental scans, check mtime to skip unchanged files.
402-
// On the first scan, track mtimes while still scanning every file.
403-
let changed = if self.has_scanned_once {
404-
match mtime {
405-
Some(mtime) => {
406-
let prev = self.mtimes.insert(path.clone(), mtime);
407-
prev.is_none_or(|prev| prev != mtime)
408-
}
409-
None => true,
413+
WalkEntry::File {
414+
path,
415+
mtime,
416+
is_symlink,
417+
} => {
418+
// Deduplicate: parallel walk can visit the same file from multiple threads
419+
if !self.files.insert(path.clone()) {
420+
continue;
410421
}
411-
} else {
412-
if let Some(mtime) = mtime {
413-
self.mtimes.insert(path.clone(), mtime);
422+
423+
// Track canonicalized paths in addition to potentially symlinked file paths
424+
let canonical = if is_symlink {
425+
dunce::canonicalize(&path).ok()
426+
} else {
427+
path.parent().and_then(|parent| {
428+
// Perf: cache the canonicalized parent path such that sibling files don't
429+
// have to canonicalize over and over again.
430+
let canonical_parent = cached_canonical_dirs
431+
.entry(parent.to_path_buf())
432+
.or_insert_with(|| {
433+
dunce::canonicalize(parent)
434+
.unwrap_or_else(|_| parent.to_path_buf())
435+
});
436+
437+
if canonical_parent.as_path() != parent {
438+
path.file_name()
439+
.map(|file_name| canonical_parent.join(file_name))
440+
} else {
441+
None
442+
}
443+
})
444+
};
445+
446+
if let Some(canonical) = canonical {
447+
if canonical != path {
448+
self.files.insert(canonical);
449+
}
414450
}
451+
let extension = path
452+
.extension()
453+
.and_then(|x| x.to_str())
454+
.unwrap_or_default()
455+
.to_owned();
456+
457+
self.extensions.insert(extension.to_owned());
458+
459+
// On incremental scans, check mtime to skip unchanged files.
460+
// On the first scan, track mtimes while still scanning every file.
461+
let changed = if self.has_scanned_once {
462+
match mtime {
463+
Some(mtime) => {
464+
let prev = self.mtimes.insert(path.clone(), mtime);
465+
prev.is_none_or(|prev| prev != mtime)
466+
}
467+
None => true,
468+
}
469+
} else {
470+
if let Some(mtime) = mtime {
471+
self.mtimes.insert(path.clone(), mtime);
472+
}
415473

416-
true
417-
};
474+
true
475+
};
418476

419-
if !changed {
420-
continue;
421-
}
477+
if !changed {
478+
continue;
479+
}
422480

423-
if let Ok(file) = path.clone().into_os_string().into_string() {
424-
changed_files.push(file);
425-
}
481+
if let Ok(file) = path.clone().into_os_string().into_string() {
482+
changed_files.push(file);
483+
}
426484

427-
match extension.as_str() {
428-
// Special handing for CSS files, we don't want to extract candidates from
429-
// these files, but we do want to extract used CSS variables.
430-
"css" => css_files.push(path),
431-
_ => content_paths.push((path, extension)),
485+
match extension.as_str() {
486+
// Special handing for CSS files, we don't want to extract candidates from
487+
// these files, but we do want to extract used CSS variables.
488+
"css" => css_files.push(path),
489+
_ => content_paths.push((path, extension)),
490+
}
432491
}
433492
}
434493
}
@@ -562,32 +621,46 @@ where
562621
.collect()
563622
}
564623

565-
type WalkEntry = (PathBuf, bool, String, Option<SystemTime>);
624+
#[derive(Debug)]
625+
enum WalkEntry {
626+
Dir(PathBuf),
627+
File {
628+
path: PathBuf,
629+
mtime: Option<SystemTime>,
566630

567-
/// Walk the file system synchronously. Used for the initial build where the overhead of spawning
568-
/// parallel walker threads is not worth it.
569-
#[tracing::instrument(skip_all)]
570-
fn walk_synchronous(walker: &mut WalkBuilder) -> Vec<WalkEntry> {
571-
let mut entries = vec![];
631+
/// Whether the path itself is a symlink
632+
is_symlink: bool,
633+
},
634+
}
572635

573-
for entry in walker.build().filter_map(Result::ok) {
636+
impl From<ignore::DirEntry> for WalkEntry {
637+
fn from(entry: ignore::DirEntry) -> Self {
574638
let is_dir = entry.file_type().map(|ft| ft.is_dir()).unwrap_or(false);
639+
let is_symlink = entry.path_is_symlink();
575640
let path = entry.into_path();
576641

577642
if is_dir {
578-
entries.push((path, true, String::new(), None));
643+
WalkEntry::Dir(path)
579644
} else {
580-
let ext = path
581-
.extension()
582-
.and_then(|x| x.to_str())
583-
.unwrap_or_default()
584-
.to_owned();
585645
let mtime = path.metadata().ok().and_then(|m| m.modified().ok());
586-
entries.push((path, false, ext, mtime));
646+
WalkEntry::File {
647+
path,
648+
mtime,
649+
is_symlink,
650+
}
587651
}
588652
}
653+
}
589654

590-
entries
655+
/// Walk the file system synchronously. Used for the initial build where the overhead of spawning
656+
/// parallel walker threads is not worth it.
657+
#[tracing::instrument(skip_all)]
658+
fn walk_synchronous(walker: &mut WalkBuilder) -> Vec<WalkEntry> {
659+
walker
660+
.build()
661+
.filter_map(Result::ok)
662+
.map(WalkEntry::from)
663+
.collect()
591664
}
592665

593666
/// Walk the file system in parallel. Used in watch mode where the parallel walker overhead is
@@ -620,20 +693,7 @@ fn walk_parallel(walker: &mut WalkBuilder) -> Vec<WalkEntry> {
620693
return ignore::WalkState::Continue;
621694
};
622695

623-
let is_dir = entry.file_type().map(|ft| ft.is_dir()).unwrap_or(false);
624-
let path = entry.into_path();
625-
626-
if is_dir {
627-
buf.local.push((path, true, String::new(), None));
628-
} else {
629-
let ext = path
630-
.extension()
631-
.and_then(|x| x.to_str())
632-
.unwrap_or_default()
633-
.to_owned();
634-
let mtime = path.metadata().ok().and_then(|m| m.modified().ok());
635-
buf.local.push((path, false, ext, mtime));
636-
}
696+
buf.local.push(WalkEntry::from(entry));
637697

638698
if buf.local.len() >= 256 {
639699
buf.shared.lock().unwrap().append(&mut buf.local);

crates/oxide/tests/scanner.rs

Lines changed: 125 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2415,6 +2415,131 @@ mod scanner {
24152415
assert_eq!(candidates, vec!["content-['project/keep.html']"]);
24162416
}
24172417

2418+
#[test]
2419+
fn test_files_behind_symlinks_are_tracked_with_their_canonical_paths() {
2420+
let dir = tempdir().unwrap().into_path();
2421+
create_files_in(
2422+
&dir,
2423+
&[
2424+
(
2425+
"packages/repro/source.html",
2426+
"content-['packages/repro/source.html']",
2427+
),
2428+
(
2429+
"packages/repro/nested/deep.html",
2430+
"content-['packages/repro/nested/deep.html']",
2431+
),
2432+
(
2433+
"packages/other/index.html",
2434+
"content-['packages/other/index.html']",
2435+
),
2436+
],
2437+
);
2438+
2439+
// Mimic a pnpm workspace where `node_modules` contains a symlink to the actual package
2440+
fs::create_dir_all(dir.join("node_modules")).unwrap();
2441+
let _ = symlink(dir.join("packages/repro"), dir.join("node_modules/repro"));
2442+
2443+
// A directly symlinked file
2444+
let _ = symlink_file(
2445+
dir.join("packages/other/index.html"),
2446+
dir.join("linked.html"),
2447+
);
2448+
2449+
let mut scanner = Scanner::new(vec![
2450+
public_source_entry_from_pattern(
2451+
dir.clone(),
2452+
"@source 'node_modules/repro/source.html'",
2453+
),
2454+
// The symlink sits multiple levels up from the file
2455+
public_source_entry_from_pattern(
2456+
dir.clone(),
2457+
"@source 'node_modules/repro/nested/deep.html'",
2458+
),
2459+
public_source_entry_from_pattern(dir.clone(), "@source 'linked.html'"),
2460+
]);
2461+
2462+
let candidates = scanner.scan();
2463+
assert_eq!(
2464+
candidates,
2465+
vec![
2466+
"content-['packages/other/index.html']",
2467+
"content-['packages/repro/nested/deep.html']",
2468+
"content-['packages/repro/source.html']",
2469+
]
2470+
);
2471+
2472+
// Both the symlinked paths and the canonical paths should be tracked, such that file
2473+
// watchers watching the returned files also watch the real files on disk.
2474+
let files = scanned_files(&mut scanner, &dir);
2475+
assert_eq!(
2476+
files,
2477+
vec![
2478+
"linked.html",
2479+
"node_modules/repro/nested/deep.html",
2480+
"node_modules/repro/source.html",
2481+
"packages/other/index.html",
2482+
"packages/repro/nested/deep.html",
2483+
"packages/repro/source.html",
2484+
]
2485+
);
2486+
}
2487+
2488+
#[test]
2489+
fn test_changes_to_the_canonical_path_of_a_symlinked_file_are_detected() {
2490+
let dir = tempdir().unwrap().into_path();
2491+
create_files_in(&dir, &[("packages/repro/source.html", "content-['v1']")]);
2492+
2493+
// Mimic a pnpm workspace where `node_modules` contains a symlink to the actual package
2494+
fs::create_dir_all(dir.join("node_modules")).unwrap();
2495+
let _ = symlink(dir.join("packages/repro"), dir.join("node_modules/repro"));
2496+
2497+
let mut scanner = Scanner::new(vec![public_source_entry_from_pattern(
2498+
dir.clone(),
2499+
"@source 'node_modules/repro/source.html'",
2500+
)]);
2501+
2502+
let candidates = scanner.scan();
2503+
assert_eq!(candidates, vec!["content-['v1']"]);
2504+
2505+
// Update the real file on disk. This is the path file watchers will report changes for.
2506+
create_files_in(&dir, &[("packages/repro/source.html", "content-['v2']")]);
2507+
2508+
let candidates = scanner.scan_content(vec![ChangedContent::File(
2509+
dir.join("packages/repro/source.html"),
2510+
"html".into(),
2511+
)]);
2512+
assert_eq!(candidates, vec!["content-['v2']"]);
2513+
}
2514+
2515+
#[test]
2516+
fn test_new_files_behind_symlinks_are_detected_at_their_canonical_path() {
2517+
let dir = tempdir().unwrap().into_path();
2518+
create_files_in(&dir, &[("packages/repro/a.html", "content-['a']")]);
2519+
2520+
// Mimic a pnpm workspace where `node_modules` contains a symlink to the actual package
2521+
fs::create_dir_all(dir.join("node_modules")).unwrap();
2522+
let _ = symlink(dir.join("packages/repro"), dir.join("node_modules/repro"));
2523+
2524+
let mut scanner = Scanner::new(vec![public_source_entry_from_pattern(
2525+
dir.clone(),
2526+
"@source 'node_modules/repro/*.html'",
2527+
)]);
2528+
2529+
let candidates = scanner.scan();
2530+
assert_eq!(candidates, vec!["content-['a']"]);
2531+
2532+
// Create a new file in the real directory. File watchers watching the real directory
2533+
// will report the new file with its canonical path.
2534+
create_files_in(&dir, &[("packages/repro/b.html", "content-['b']")]);
2535+
2536+
let candidates = scanner.scan_content(vec![ChangedContent::File(
2537+
dir.join("packages/repro/b.html"),
2538+
"html".into(),
2539+
)]);
2540+
assert_eq!(candidates, vec!["content-['b']"]);
2541+
}
2542+
24182543
#[test]
24192544
fn test_extract_used_css_variables_from_css() {
24202545
let dir = tempdir().unwrap().into_path();

0 commit comments

Comments
 (0)