Fix changes to symlinked files outside of the project works (#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: #20346 Closes: #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: | | 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>
This commit is contained in:
parent
4c128662c2
commit
6de87c62c4
4 changed files with 366 additions and 72 deletions
|
|
@ -7,7 +7,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|||
|
||||
## [Unreleased]
|
||||
|
||||
- Nothing yet!
|
||||
### Fixed
|
||||
|
||||
- Ensure watch mode detects changes to symlinked `@source` files whose real paths aren't otherwise scanned ([#20356](https://github.com/tailwindlabs/tailwindcss/pull/20356))
|
||||
|
||||
## [4.3.3] - 2026-07-16
|
||||
|
||||
|
|
|
|||
|
|
@ -181,6 +181,11 @@ impl Scanner {
|
|||
continue;
|
||||
}
|
||||
|
||||
// The walked path can contain symlinks, while the changed files have already
|
||||
// been canonicalized. Lazily canonicalize the walked path so we can compare
|
||||
// the real paths as well.
|
||||
let mut canonical_path: Option<PathBuf> = None;
|
||||
|
||||
let mut drop_file_indexes = vec![];
|
||||
for (idx, changed_file) in new_unknown_files.iter().enumerate().rev() {
|
||||
let ChangedContent::File(file, _) = changed_file else {
|
||||
|
|
@ -189,7 +194,15 @@ impl Scanner {
|
|||
|
||||
// When the file is found on disk it means that all the rules pass. We can
|
||||
// extract the current file and remove it from the list of passed in files.
|
||||
if file == path {
|
||||
let matches = file == path
|
||||
|| (file.file_name() == path.file_name() && {
|
||||
if canonical_path.is_none() {
|
||||
canonical_path = dunce::canonicalize(path).ok();
|
||||
}
|
||||
canonical_path.as_deref() == Some(file.as_path())
|
||||
});
|
||||
|
||||
if matches {
|
||||
self.files.insert(path.to_path_buf()); // Track for future use
|
||||
content_to_scan.push(changed_file.clone()); // Track for parsing
|
||||
drop_file_indexes.push(idx);
|
||||
|
|
@ -388,47 +401,93 @@ impl Scanner {
|
|||
self.extensions.clear();
|
||||
self.globs = None;
|
||||
|
||||
for (path, is_dir, extension, mtime) in all_entries {
|
||||
if is_dir {
|
||||
self.dirs.insert(path);
|
||||
} else {
|
||||
// Deduplicate: parallel walk can visit the same file from multiple threads
|
||||
if !self.files.insert(path.clone()) {
|
||||
continue;
|
||||
}
|
||||
self.extensions.insert(extension.clone());
|
||||
// Cache canonicalized folders in case a file itself is not symlinked, but any of the parent
|
||||
// folders are symlinked.
|
||||
let mut cached_canonical_dirs: FxHashMap<PathBuf, PathBuf> = FxHashMap::default();
|
||||
|
||||
// On incremental scans, check mtime to skip unchanged files.
|
||||
// On the first scan, track mtimes while still scanning every file.
|
||||
let changed = if self.has_scanned_once {
|
||||
match mtime {
|
||||
Some(mtime) => {
|
||||
let prev = self.mtimes.insert(path.clone(), mtime);
|
||||
prev.is_none_or(|prev| prev != mtime)
|
||||
for entry in all_entries {
|
||||
match entry {
|
||||
WalkEntry::Dir(path) => {
|
||||
self.dirs.insert(path);
|
||||
}
|
||||
WalkEntry::File {
|
||||
path,
|
||||
mtime,
|
||||
is_symlink,
|
||||
} => {
|
||||
// Deduplicate: parallel walk can visit the same file from multiple threads
|
||||
if !self.files.insert(path.clone()) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Track canonicalized paths in addition to potentially symlinked file paths
|
||||
let canonical = if is_symlink {
|
||||
dunce::canonicalize(&path).ok()
|
||||
} else {
|
||||
path.parent().and_then(|parent| {
|
||||
// Perf: cache the canonicalized parent path such that sibling files don't
|
||||
// have to canonicalize over and over again.
|
||||
let canonical_parent = cached_canonical_dirs
|
||||
.entry(parent.to_path_buf())
|
||||
.or_insert_with(|| {
|
||||
dunce::canonicalize(parent)
|
||||
.unwrap_or_else(|_| parent.to_path_buf())
|
||||
});
|
||||
|
||||
if canonical_parent.as_path() != parent {
|
||||
path.file_name()
|
||||
.map(|file_name| canonical_parent.join(file_name))
|
||||
} else {
|
||||
None
|
||||
}
|
||||
})
|
||||
};
|
||||
|
||||
if let Some(canonical) = canonical {
|
||||
if canonical != path {
|
||||
self.files.insert(canonical);
|
||||
}
|
||||
None => true,
|
||||
}
|
||||
} else {
|
||||
if let Some(mtime) = mtime {
|
||||
self.mtimes.insert(path.clone(), mtime);
|
||||
let extension = path
|
||||
.extension()
|
||||
.and_then(|x| x.to_str())
|
||||
.unwrap_or_default()
|
||||
.to_owned();
|
||||
|
||||
self.extensions.insert(extension.to_owned());
|
||||
|
||||
// On incremental scans, check mtime to skip unchanged files.
|
||||
// On the first scan, track mtimes while still scanning every file.
|
||||
let changed = if self.has_scanned_once {
|
||||
match mtime {
|
||||
Some(mtime) => {
|
||||
let prev = self.mtimes.insert(path.clone(), mtime);
|
||||
prev.is_none_or(|prev| prev != mtime)
|
||||
}
|
||||
None => true,
|
||||
}
|
||||
} else {
|
||||
if let Some(mtime) = mtime {
|
||||
self.mtimes.insert(path.clone(), mtime);
|
||||
}
|
||||
|
||||
true
|
||||
};
|
||||
|
||||
if !changed {
|
||||
continue;
|
||||
}
|
||||
|
||||
true
|
||||
};
|
||||
if let Ok(file) = path.clone().into_os_string().into_string() {
|
||||
changed_files.push(file);
|
||||
}
|
||||
|
||||
if !changed {
|
||||
continue;
|
||||
}
|
||||
|
||||
if let Ok(file) = path.clone().into_os_string().into_string() {
|
||||
changed_files.push(file);
|
||||
}
|
||||
|
||||
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" => css_files.push(path),
|
||||
_ => content_paths.push((path, extension)),
|
||||
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" => css_files.push(path),
|
||||
_ => content_paths.push((path, extension)),
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -562,32 +621,46 @@ where
|
|||
.collect()
|
||||
}
|
||||
|
||||
type WalkEntry = (PathBuf, bool, String, Option<SystemTime>);
|
||||
#[derive(Debug)]
|
||||
enum WalkEntry {
|
||||
Dir(PathBuf),
|
||||
File {
|
||||
path: PathBuf,
|
||||
mtime: Option<SystemTime>,
|
||||
|
||||
/// Whether the path itself is a symlink
|
||||
is_symlink: bool,
|
||||
},
|
||||
}
|
||||
|
||||
impl From<ignore::DirEntry> for WalkEntry {
|
||||
fn from(entry: ignore::DirEntry) -> Self {
|
||||
let is_dir = entry.file_type().map(|ft| ft.is_dir()).unwrap_or(false);
|
||||
let is_symlink = entry.path_is_symlink();
|
||||
let path = entry.into_path();
|
||||
|
||||
if is_dir {
|
||||
WalkEntry::Dir(path)
|
||||
} else {
|
||||
let mtime = path.metadata().ok().and_then(|m| m.modified().ok());
|
||||
WalkEntry::File {
|
||||
path,
|
||||
mtime,
|
||||
is_symlink,
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Walk the file system synchronously. Used for the initial build where the overhead of spawning
|
||||
/// parallel walker threads is not worth it.
|
||||
#[tracing::instrument(skip_all)]
|
||||
fn walk_synchronous(walker: &mut WalkBuilder) -> Vec<WalkEntry> {
|
||||
let mut entries = vec![];
|
||||
|
||||
for entry in walker.build().filter_map(Result::ok) {
|
||||
let is_dir = entry.file_type().map(|ft| ft.is_dir()).unwrap_or(false);
|
||||
let path = entry.into_path();
|
||||
|
||||
if is_dir {
|
||||
entries.push((path, true, String::new(), None));
|
||||
} else {
|
||||
let ext = path
|
||||
.extension()
|
||||
.and_then(|x| x.to_str())
|
||||
.unwrap_or_default()
|
||||
.to_owned();
|
||||
let mtime = path.metadata().ok().and_then(|m| m.modified().ok());
|
||||
entries.push((path, false, ext, mtime));
|
||||
}
|
||||
}
|
||||
|
||||
entries
|
||||
walker
|
||||
.build()
|
||||
.filter_map(Result::ok)
|
||||
.map(WalkEntry::from)
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// 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> {
|
|||
return ignore::WalkState::Continue;
|
||||
};
|
||||
|
||||
let is_dir = entry.file_type().map(|ft| ft.is_dir()).unwrap_or(false);
|
||||
let path = entry.into_path();
|
||||
|
||||
if is_dir {
|
||||
buf.local.push((path, true, String::new(), None));
|
||||
} else {
|
||||
let ext = path
|
||||
.extension()
|
||||
.and_then(|x| x.to_str())
|
||||
.unwrap_or_default()
|
||||
.to_owned();
|
||||
let mtime = path.metadata().ok().and_then(|m| m.modified().ok());
|
||||
buf.local.push((path, false, ext, mtime));
|
||||
}
|
||||
buf.local.push(WalkEntry::from(entry));
|
||||
|
||||
if buf.local.len() >= 256 {
|
||||
buf.shared.lock().unwrap().append(&mut buf.local);
|
||||
|
|
|
|||
|
|
@ -2415,6 +2415,131 @@ mod scanner {
|
|||
assert_eq!(candidates, vec!["content-['project/keep.html']"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_files_behind_symlinks_are_tracked_with_their_canonical_paths() {
|
||||
let dir = tempdir().unwrap().into_path();
|
||||
create_files_in(
|
||||
&dir,
|
||||
&[
|
||||
(
|
||||
"packages/repro/source.html",
|
||||
"content-['packages/repro/source.html']",
|
||||
),
|
||||
(
|
||||
"packages/repro/nested/deep.html",
|
||||
"content-['packages/repro/nested/deep.html']",
|
||||
),
|
||||
(
|
||||
"packages/other/index.html",
|
||||
"content-['packages/other/index.html']",
|
||||
),
|
||||
],
|
||||
);
|
||||
|
||||
// Mimic a pnpm workspace where `node_modules` contains a symlink to the actual package
|
||||
fs::create_dir_all(dir.join("node_modules")).unwrap();
|
||||
let _ = symlink(dir.join("packages/repro"), dir.join("node_modules/repro"));
|
||||
|
||||
// A directly symlinked file
|
||||
let _ = symlink_file(
|
||||
dir.join("packages/other/index.html"),
|
||||
dir.join("linked.html"),
|
||||
);
|
||||
|
||||
let mut scanner = Scanner::new(vec![
|
||||
public_source_entry_from_pattern(
|
||||
dir.clone(),
|
||||
"@source 'node_modules/repro/source.html'",
|
||||
),
|
||||
// The symlink sits multiple levels up from the file
|
||||
public_source_entry_from_pattern(
|
||||
dir.clone(),
|
||||
"@source 'node_modules/repro/nested/deep.html'",
|
||||
),
|
||||
public_source_entry_from_pattern(dir.clone(), "@source 'linked.html'"),
|
||||
]);
|
||||
|
||||
let candidates = scanner.scan();
|
||||
assert_eq!(
|
||||
candidates,
|
||||
vec![
|
||||
"content-['packages/other/index.html']",
|
||||
"content-['packages/repro/nested/deep.html']",
|
||||
"content-['packages/repro/source.html']",
|
||||
]
|
||||
);
|
||||
|
||||
// Both the symlinked paths and the canonical paths should be tracked, such that file
|
||||
// watchers watching the returned files also watch the real files on disk.
|
||||
let files = scanned_files(&mut scanner, &dir);
|
||||
assert_eq!(
|
||||
files,
|
||||
vec![
|
||||
"linked.html",
|
||||
"node_modules/repro/nested/deep.html",
|
||||
"node_modules/repro/source.html",
|
||||
"packages/other/index.html",
|
||||
"packages/repro/nested/deep.html",
|
||||
"packages/repro/source.html",
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_changes_to_the_canonical_path_of_a_symlinked_file_are_detected() {
|
||||
let dir = tempdir().unwrap().into_path();
|
||||
create_files_in(&dir, &[("packages/repro/source.html", "content-['v1']")]);
|
||||
|
||||
// Mimic a pnpm workspace where `node_modules` contains a symlink to the actual package
|
||||
fs::create_dir_all(dir.join("node_modules")).unwrap();
|
||||
let _ = symlink(dir.join("packages/repro"), dir.join("node_modules/repro"));
|
||||
|
||||
let mut scanner = Scanner::new(vec![public_source_entry_from_pattern(
|
||||
dir.clone(),
|
||||
"@source 'node_modules/repro/source.html'",
|
||||
)]);
|
||||
|
||||
let candidates = scanner.scan();
|
||||
assert_eq!(candidates, vec!["content-['v1']"]);
|
||||
|
||||
// Update the real file on disk. This is the path file watchers will report changes for.
|
||||
create_files_in(&dir, &[("packages/repro/source.html", "content-['v2']")]);
|
||||
|
||||
let candidates = scanner.scan_content(vec![ChangedContent::File(
|
||||
dir.join("packages/repro/source.html"),
|
||||
"html".into(),
|
||||
)]);
|
||||
assert_eq!(candidates, vec!["content-['v2']"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_new_files_behind_symlinks_are_detected_at_their_canonical_path() {
|
||||
let dir = tempdir().unwrap().into_path();
|
||||
create_files_in(&dir, &[("packages/repro/a.html", "content-['a']")]);
|
||||
|
||||
// Mimic a pnpm workspace where `node_modules` contains a symlink to the actual package
|
||||
fs::create_dir_all(dir.join("node_modules")).unwrap();
|
||||
let _ = symlink(dir.join("packages/repro"), dir.join("node_modules/repro"));
|
||||
|
||||
let mut scanner = Scanner::new(vec![public_source_entry_from_pattern(
|
||||
dir.clone(),
|
||||
"@source 'node_modules/repro/*.html'",
|
||||
)]);
|
||||
|
||||
let candidates = scanner.scan();
|
||||
assert_eq!(candidates, vec!["content-['a']"]);
|
||||
|
||||
// Create a new file in the real directory. File watchers watching the real directory
|
||||
// will report the new file with its canonical path.
|
||||
create_files_in(&dir, &[("packages/repro/b.html", "content-['b']")]);
|
||||
|
||||
let candidates = scanner.scan_content(vec![ChangedContent::File(
|
||||
dir.join("packages/repro/b.html"),
|
||||
"html".into(),
|
||||
)]);
|
||||
assert_eq!(candidates, vec!["content-['b']"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_extract_used_css_variables_from_css() {
|
||||
let dir = tempdir().unwrap().into_path();
|
||||
|
|
|
|||
|
|
@ -294,6 +294,113 @@ describe.each(['postcss', 'lightningcss'])('%s', (transformer) => {
|
|||
},
|
||||
)
|
||||
|
||||
// https://github.com/tailwindlabs/tailwindcss/issues/20346
|
||||
test(
|
||||
'dev mode + symlinked `@source` files pick up changes to the real file',
|
||||
{
|
||||
fs: {
|
||||
'package.json': json`{}`,
|
||||
'pnpm-workspace.yaml': yaml`
|
||||
#
|
||||
packages:
|
||||
- project-a
|
||||
- packages/*
|
||||
`,
|
||||
'project-a/package.json': txt`
|
||||
{
|
||||
"type": "module",
|
||||
"dependencies": {
|
||||
"@tailwindcss/vite": "workspace:^",
|
||||
"repro-package": "workspace:*",
|
||||
"tailwindcss": "workspace:^"
|
||||
},
|
||||
"devDependencies": {
|
||||
${transformer === 'lightningcss' ? `"lightningcss": "^1",` : ''}
|
||||
"vite": "^7"
|
||||
}
|
||||
}
|
||||
`,
|
||||
'project-a/vite.config.ts': ts`
|
||||
import tailwindcss from '@tailwindcss/vite'
|
||||
import { defineConfig } from 'vite'
|
||||
|
||||
export default defineConfig({
|
||||
css: ${transformer === 'postcss' ? '{}' : "{ transformer: 'lightningcss' }"},
|
||||
build: { cssMinify: false },
|
||||
plugins: [tailwindcss()],
|
||||
})
|
||||
`,
|
||||
'project-a/index.html': html`
|
||||
<head>
|
||||
<link rel="stylesheet" href="./src/index.css" />
|
||||
</head>
|
||||
<body>
|
||||
<div class="underline">Hello, world!</div>
|
||||
</body>
|
||||
`,
|
||||
|
||||
// The `@source` points through the `node_modules` symlink that pnpm
|
||||
// creates for the workspace package. Vite's file watcher ignores
|
||||
// `node_modules` by default, so the symlinked path can not be watched.
|
||||
// The real file (in `packages/repro-package`) has to be watched
|
||||
// instead.
|
||||
'project-a/src/index.css': css`
|
||||
@reference 'tailwindcss/theme';
|
||||
@import 'tailwindcss/utilities';
|
||||
@source '../node_modules/repro-package/source.html';
|
||||
`,
|
||||
'packages/repro-package/package.json': json`
|
||||
{
|
||||
"name": "repro-package",
|
||||
"private": true,
|
||||
"version": "1.0.0"
|
||||
}
|
||||
`,
|
||||
'packages/repro-package/source.html': html`
|
||||
<div class="content-['v1']">
|
||||
Hello, world!
|
||||
</div>
|
||||
`,
|
||||
},
|
||||
},
|
||||
async ({ root, spawn, fs, expect }) => {
|
||||
let process = await spawn('pnpm vite dev', {
|
||||
cwd: path.join(root, 'project-a'),
|
||||
})
|
||||
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 styles = await fetchStyles(url, '/index.html')
|
||||
expect(styles).toContain(candidate`underline`)
|
||||
expect(styles).toContain(candidate`content-['v1']`)
|
||||
})
|
||||
|
||||
await retryAssertion(async () => {
|
||||
// Changes to the real file (behind the symlink) should be picked up
|
||||
await fs.write(
|
||||
'packages/repro-package/source.html',
|
||||
html`
|
||||
<div class="content-['v1'] content-['v2']">
|
||||
Hello, world!
|
||||
</div>
|
||||
`,
|
||||
)
|
||||
|
||||
let styles = await fetchStyles(url)
|
||||
expect(styles).toContain(candidate`underline`)
|
||||
expect(styles).toContain(candidate`content-['v1']`)
|
||||
expect(styles).toContain(candidate`content-['v2']`)
|
||||
})
|
||||
},
|
||||
)
|
||||
|
||||
test(
|
||||
'watch mode',
|
||||
{
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue