Add --poll option to @tailwindcss/cli (#20297)
This PR re-adds the `--poll` option to the `@tailwindcss/cli` that we had in Tailwind CSS v3, but didn't in Tailwind CSS v4. In Tailwind CSS v4, we started using `@parcel/watcher` instead of chokidar for our watcher in the CLI. However, this currently doesn't support a `--poll` option. This PR implements our own `--poll` option such that you can use it in environments where fs events don't work properly (e.g. Docker). Polling can be enabled by using `--watch --poll`, in this case we will poll every `250ms` (I'm open for a different default value). You can also pick your own interval by using `--watch --poll 500` which is defined in milliseconds. The `--poll` option will be less efficient than a normal `--watch`. But if you are in a situation where you can't use `--watch` on its own then this is a good fallback. One thing you can do today is run the build command manually. If you do have some tooling that _does_ work on your machine (such as [`watchexec`](https://github.com/watchexec/watchexec)) then you can automatically perform a full build. The biggest downside of this approach is that you are doing a full build every time, instead of an incremental build. With this PR, we try to fix that by still allowing incremental builds. This should result in the same behavior as the normal `--watch` function: 1. First run, will trigger a full build 1. When any of the source files changes: - If all classes were already known, then it will be a no-op, but you will see a log in the terminal about it. - If a new class is detected, then the CSS will be updated, but it will be much more efficient than a full rebuild 1. When the input CSS file changes, or any of its dependencies, then a full rebuild will be triggered (such that your new `@utility` are available, and `@theme` values are updated). The implementation is a little bit more complex just because I didn't want to spam the terminal output even if we are polling every `250ms`. In the Oxide scanner we do track the modified times of each file. Every `250ms` we traverse the file system and skip the files that we know didn't change (since the mtime is the same). If the file was touched, then we will parse it again to extract possible Tailwind CSS classes. We will also track which files were scanned such that we can know whether we have to trigger a full-rebuild or not (in case the input.css file or any of its dependencies was changed). Fixes: #18109 Fixes: #18540 Fixes: #15750 ## Test plan 1. Existing tests pass 2. An integration test has been added for the `--poll` option 3. Tested it on the tailwindcss.com codebase: <img width="679" height="320" alt="image" src="https://github.com/user-attachments/assets/af858da5-3e07-448d-86bd-8eacdf3bf8d1" /> Annotated: ``` ≈ tailwindcss v4.3.2 Done in 105ms Initial build Done in 3ms Saved a file that resulted in a no-op Done in 2ms Saved a file that resulted in a no-op Done in 3ms Saved a file that resulted in a no-op Done in 53ms Saved a file with a new class Done in 2ms Saved a file that resulted in a no-op Done in 2ms Saved a file that resulted in a no-op Done in 3ms Saved a file that resulted in a no-op Done in 88ms Saved a the input.css file Done in 3ms Saved a file that resulted in a no-op Polling for changes… ``` We check the file system every `250ms` by default, but we won't log to prevent spamming the terminal.
This commit is contained in:
parent
056a155072
commit
39656f7d9a
8 changed files with 418 additions and 54 deletions
|
|
@ -7,7 +7,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|||
|
||||
## [Unreleased]
|
||||
|
||||
- Nothing yet!
|
||||
### Fixed
|
||||
|
||||
- Allow `@tailwindcss/cli` in `--watch` mode to use polling with `--poll` when filesystem events are unreliable or unavailable ([#20297](https://github.com/tailwindlabs/tailwindcss/pull/20297))
|
||||
|
||||
## [4.3.2] - 2026-06-26
|
||||
|
||||
|
|
|
|||
|
|
@ -165,6 +165,11 @@ impl Scanner {
|
|||
self.scanner.get_files()
|
||||
}
|
||||
|
||||
#[napi(getter)]
|
||||
pub fn scanned_files(&self) -> Vec<String> {
|
||||
self.scanner.get_scanned_files()
|
||||
}
|
||||
|
||||
#[napi(getter)]
|
||||
pub fn globs(&mut self) -> Vec<GlobEntry> {
|
||||
self
|
||||
|
|
|
|||
|
|
@ -78,11 +78,12 @@ pub struct Scanner {
|
|||
/// Track unique set of candidates
|
||||
candidates: FxHashSet<String>,
|
||||
|
||||
/// Track mtimes for files so re-scans can skip unchanged files.
|
||||
/// Only populated after the first scan completes (to avoid unnecessary
|
||||
/// metadata calls on initial build).
|
||||
/// Track mtimes for files so incremental scans can skip unchanged files.
|
||||
mtimes: FxHashMap<PathBuf, SystemTime>,
|
||||
|
||||
/// Files that were scanned during the last `scan()` call.
|
||||
scanned_files: Vec<String>,
|
||||
|
||||
/// Whether we've completed at least one full scan. When false, we skip
|
||||
/// mtime tracking entirely so the initial build stays fast.
|
||||
has_scanned_once: bool,
|
||||
|
|
@ -122,9 +123,10 @@ impl Scanner {
|
|||
pub fn scan(&mut self) -> Vec<String> {
|
||||
self.sources_scanned = false;
|
||||
|
||||
let (scanned_blobs, css_files) = self.discover_sources();
|
||||
let (scanned_blobs, css_files, files) = self.discover_sources();
|
||||
|
||||
self.extract_candidates(scanned_blobs, css_files);
|
||||
self.scanned_files = files;
|
||||
|
||||
// Return all candidates sorted
|
||||
let mut result = self.candidates.iter().cloned().collect::<Vec<_>>();
|
||||
|
|
@ -256,6 +258,11 @@ impl Scanner {
|
|||
.collect()
|
||||
}
|
||||
|
||||
#[tracing::instrument(skip_all)]
|
||||
pub fn get_scanned_files(&self) -> Vec<String> {
|
||||
self.scanned_files.clone()
|
||||
}
|
||||
|
||||
#[tracing::instrument(skip_all)]
|
||||
pub fn get_globs(&mut self) -> Vec<GlobEntry> {
|
||||
if let Some(globs) = &self.globs {
|
||||
|
|
@ -353,14 +360,14 @@ impl Scanner {
|
|||
}
|
||||
|
||||
#[tracing::instrument(skip_all)]
|
||||
fn discover_sources(&mut self) -> (Vec<Vec<u8>>, Vec<PathBuf>) {
|
||||
fn discover_sources(&mut self) -> (Vec<Vec<u8>>, Vec<PathBuf>, Vec<String>) {
|
||||
if self.sources_scanned {
|
||||
return (vec![], vec![]);
|
||||
return (vec![], vec![], vec![]);
|
||||
}
|
||||
self.sources_scanned = true;
|
||||
|
||||
let Some(walker) = &mut self.walker else {
|
||||
return (vec![], vec![]);
|
||||
return (vec![], vec![], vec![]);
|
||||
};
|
||||
|
||||
// Use synchronous walk for the initial build (lower overhead) and parallel
|
||||
|
|
@ -372,7 +379,8 @@ impl Scanner {
|
|||
};
|
||||
|
||||
let mut css_files: Vec<PathBuf> = vec![];
|
||||
let mut content_paths: Vec<(PathBuf, String)> = Vec::new();
|
||||
let mut content_paths: Vec<(PathBuf, String)> = vec![];
|
||||
let mut changed_files = vec![];
|
||||
|
||||
// Fresh state
|
||||
self.files.clear();
|
||||
|
|
@ -380,7 +388,7 @@ impl Scanner {
|
|||
self.extensions.clear();
|
||||
self.globs = None;
|
||||
|
||||
for (path, is_dir, extension) in all_entries {
|
||||
for (path, is_dir, extension, mtime) in all_entries {
|
||||
if is_dir {
|
||||
self.dirs.insert(path);
|
||||
} else {
|
||||
|
|
@ -390,13 +398,10 @@ impl Scanner {
|
|||
}
|
||||
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.
|
||||
// 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 {
|
||||
let current_mtime = path.metadata().ok().and_then(|m| m.modified().ok());
|
||||
|
||||
match current_mtime {
|
||||
match mtime {
|
||||
Some(mtime) => {
|
||||
let prev = self.mtimes.insert(path.clone(), mtime);
|
||||
prev.is_none_or(|prev| prev != mtime)
|
||||
|
|
@ -404,6 +409,10 @@ impl Scanner {
|
|||
None => true,
|
||||
}
|
||||
} else {
|
||||
if let Some(mtime) = mtime {
|
||||
self.mtimes.insert(path.clone(), mtime);
|
||||
}
|
||||
|
||||
true
|
||||
};
|
||||
|
||||
|
|
@ -411,6 +420,10 @@ impl Scanner {
|
|||
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.
|
||||
|
|
@ -442,7 +455,9 @@ impl Scanner {
|
|||
self.has_scanned_once = true;
|
||||
}
|
||||
|
||||
(scanned_blobs, css_files)
|
||||
changed_files.par_sort_unstable();
|
||||
|
||||
(scanned_blobs, css_files, changed_files)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -547,27 +562,28 @@ where
|
|||
.collect()
|
||||
}
|
||||
|
||||
type WalkEntry = (PathBuf, bool, String);
|
||||
type WalkEntry = (PathBuf, bool, String, Option<SystemTime>);
|
||||
|
||||
/// 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::new();
|
||||
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()));
|
||||
entries.push((path, true, String::new(), None));
|
||||
} else {
|
||||
let ext = path
|
||||
.extension()
|
||||
.and_then(|x| x.to_str())
|
||||
.unwrap_or_default()
|
||||
.to_owned();
|
||||
entries.push((path, false, ext));
|
||||
let mtime = path.metadata().ok().and_then(|m| m.modified().ok());
|
||||
entries.push((path, false, ext, mtime));
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -591,7 +607,7 @@ fn walk_parallel(walker: &mut WalkBuilder) -> Vec<WalkEntry> {
|
|||
}
|
||||
}
|
||||
|
||||
let collected: Arc<Mutex<Vec<WalkEntry>>> = Arc::new(Mutex::new(Vec::new()));
|
||||
let collected: Arc<Mutex<Vec<WalkEntry>>> = Arc::new(Mutex::new(vec![]));
|
||||
|
||||
walker.build_parallel().run(|| {
|
||||
let mut buf = FlushOnDrop {
|
||||
|
|
@ -608,14 +624,15 @@ fn walk_parallel(walker: &mut WalkBuilder) -> Vec<WalkEntry> {
|
|||
let path = entry.into_path();
|
||||
|
||||
if is_dir {
|
||||
buf.local.push((path, true, String::new()));
|
||||
buf.local.push((path, true, String::new(), None));
|
||||
} else {
|
||||
let ext = path
|
||||
.extension()
|
||||
.and_then(|x| x.to_str())
|
||||
.unwrap_or_default()
|
||||
.to_owned();
|
||||
buf.local.push((path, false, ext));
|
||||
let mtime = path.metadata().ok().and_then(|m| m.modified().ok());
|
||||
buf.local.push((path, false, ext, mtime));
|
||||
}
|
||||
|
||||
if buf.local.len() >= 256 {
|
||||
|
|
|
|||
|
|
@ -106,6 +106,18 @@ mod scanner {
|
|||
globs
|
||||
}
|
||||
|
||||
fn normalize_files(files: Vec<String>, base: &Path) -> Vec<String> {
|
||||
let base_dir =
|
||||
format!("{}{}", dunce::canonicalize(base).unwrap().display(), "/").replace('\\', "/");
|
||||
|
||||
let mut files = files
|
||||
.iter()
|
||||
.map(|file| file.replace('\\', "/").replace(&base_dir, ""))
|
||||
.collect::<Vec<_>>();
|
||||
files.sort();
|
||||
files
|
||||
}
|
||||
|
||||
fn scan_with_globs(
|
||||
paths_with_content: &[(&str, &str)],
|
||||
source_directives: Vec<&str>,
|
||||
|
|
@ -1045,6 +1057,53 @@ mod scanner {
|
|||
assert!(!globs.iter().any(|glob| glob.starts_with("src/**/*")));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn it_should_track_files_scanned_by_the_last_scan() {
|
||||
let dir = tempdir().unwrap().into_path();
|
||||
|
||||
let _ = Command::new("git").arg("init").current_dir(&dir).output();
|
||||
|
||||
create_files_in(
|
||||
&dir,
|
||||
&[
|
||||
("src/index.html", "content-['src/index.html']"),
|
||||
("src/keep.html", "content-['src/keep.html']"),
|
||||
],
|
||||
);
|
||||
|
||||
let mut scanner = Scanner::new(vec![public_source_entry_from_pattern(
|
||||
dir.clone(),
|
||||
"@source '**/*'",
|
||||
)]);
|
||||
|
||||
assert_eq!(
|
||||
scanner.scan(),
|
||||
vec!["content-['src/index.html']", "content-['src/keep.html']"]
|
||||
);
|
||||
|
||||
assert_eq!(
|
||||
scanner.scan(),
|
||||
vec!["content-['src/index.html']", "content-['src/keep.html']"]
|
||||
);
|
||||
assert_eq!(scanner.get_scanned_files(), Vec::<String>::new());
|
||||
|
||||
sleep(Duration::from_millis(10));
|
||||
fs::write(dir.join("src/index.html"), "content-['src/changed.html']").unwrap();
|
||||
|
||||
assert_eq!(
|
||||
scanner.scan(),
|
||||
vec![
|
||||
"content-['src/changed.html']",
|
||||
"content-['src/index.html']",
|
||||
"content-['src/keep.html']",
|
||||
]
|
||||
);
|
||||
assert_eq!(
|
||||
normalize_files(scanner.get_scanned_files(), &dir),
|
||||
vec!["src/index.html"]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn it_should_ignore_negated_custom_sources() {
|
||||
let ScanResult {
|
||||
|
|
|
|||
|
|
@ -375,6 +375,40 @@ describe.each([
|
|||
},
|
||||
)
|
||||
|
||||
test(
|
||||
'watch mode with polling',
|
||||
{
|
||||
fs: {
|
||||
'package.json': json`
|
||||
{
|
||||
"dependencies": {
|
||||
"tailwindcss": "workspace:^",
|
||||
"@tailwindcss/cli": "workspace:^"
|
||||
}
|
||||
}
|
||||
`,
|
||||
'src/index.css': css`@import 'tailwindcss/utilities';`,
|
||||
'src/index.html': html`
|
||||
<div class="underline"></div>
|
||||
`,
|
||||
},
|
||||
},
|
||||
async ({ fs, spawn }) => {
|
||||
let process = await spawn(
|
||||
`${command} --input src/index.css --output dist/out.css --watch --poll=50`,
|
||||
)
|
||||
await process.onStderr((m) => m.includes('Done in'))
|
||||
|
||||
await fs.expectFileToContain('dist/out.css', [candidate`underline`])
|
||||
|
||||
await fs.write('src/index.html', html`
|
||||
<div class="underline flex"></div>
|
||||
`)
|
||||
|
||||
await fs.expectFileToContain('dist/out.css', [candidate`flex`])
|
||||
},
|
||||
)
|
||||
|
||||
test(
|
||||
"watch mode with unknown @source paths shouldn't crash on Windows",
|
||||
{
|
||||
|
|
|
|||
|
|
@ -26,6 +26,7 @@ import { drainStdin, outputFile } from './utils'
|
|||
|
||||
const css = String.raw
|
||||
const DEBUG = env.DEBUG
|
||||
const DEFAULT_POLL_INTERVAL_MS = 250
|
||||
|
||||
export function options() {
|
||||
return {
|
||||
|
|
@ -47,6 +48,12 @@ export function options() {
|
|||
alias: '-w',
|
||||
values: ['always'],
|
||||
},
|
||||
'--poll': {
|
||||
type: 'boolean | number',
|
||||
description: 'Use polling instead of filesystem events when watching',
|
||||
default: false,
|
||||
values: ['ms'],
|
||||
},
|
||||
'--minify': {
|
||||
type: 'boolean',
|
||||
description: 'Optimize and minify the output',
|
||||
|
|
@ -129,7 +136,17 @@ export async function handle(args: Result<ReturnType<typeof options>>) {
|
|||
// If the user passes `{bin} build --map -` then this likely means they want to output the map inline
|
||||
// this is the default behavior of `{bin build} --map` to inform the user of that
|
||||
if (args['--map'] === '-') {
|
||||
eprintln(`Use --map without a value to inline the source map`)
|
||||
eprintln(`Use --map without a value to inline the source map.`)
|
||||
process.exit(1)
|
||||
}
|
||||
|
||||
if (args['--poll'] === undefined) {
|
||||
eprintln(`Use --poll with a non-zero value in milliseconds.`)
|
||||
process.exit(1)
|
||||
}
|
||||
let pollInterval = args['--poll'] === true ? DEFAULT_POLL_INTERVAL_MS : args['--poll']
|
||||
if (pollInterval !== false && pollInterval <= 0) {
|
||||
eprintln(`Specified polling interval must be a positive number.`)
|
||||
process.exit(1)
|
||||
}
|
||||
|
||||
|
|
@ -152,6 +169,7 @@ export async function handle(args: Result<ReturnType<typeof options>>) {
|
|||
let previous = {
|
||||
css: '',
|
||||
optimizedCss: '',
|
||||
output: null as string | null,
|
||||
}
|
||||
|
||||
async function write(
|
||||
|
|
@ -208,13 +226,18 @@ export async function handle(args: Result<ReturnType<typeof options>>) {
|
|||
}
|
||||
}
|
||||
|
||||
let didChange = output !== previous.output
|
||||
|
||||
DEBUG && I.start('Write output')
|
||||
if (args['--output'] && args['--output'] !== '-') {
|
||||
await outputFile(args['--output'], output)
|
||||
} else {
|
||||
} else if (didChange) {
|
||||
println(output)
|
||||
}
|
||||
DEBUG && I.end('Write output')
|
||||
|
||||
previous.output = output
|
||||
return didChange
|
||||
}
|
||||
|
||||
let inputFilePath =
|
||||
|
|
@ -275,10 +298,10 @@ export async function handle(args: Result<ReturnType<typeof options>>) {
|
|||
}
|
||||
|
||||
let [compiler, scanner] = await handleError(() => createCompiler(input, I))
|
||||
let cleanupWatchers: (() => Promise<void>)[] = []
|
||||
|
||||
// Watch for changes
|
||||
if (args['--watch']) {
|
||||
let cleanupWatchers: (() => Promise<void>)[] = []
|
||||
if (args['--watch'] && pollInterval === false) {
|
||||
cleanupWatchers.push(
|
||||
await createWatchers(await watchDirectories(scanner), async function handle(files) {
|
||||
try {
|
||||
|
|
@ -292,36 +315,15 @@ export async function handle(args: Result<ReturnType<typeof options>>) {
|
|||
// Re-compile the input
|
||||
let start = process.hrtime.bigint()
|
||||
|
||||
let changedFiles: ChangedContent[] = []
|
||||
let rebuildStrategy: 'incremental' | 'full' = 'incremental'
|
||||
|
||||
let resolvedFullRebuildPaths = fullRebuildPaths
|
||||
|
||||
for (let file of files) {
|
||||
// If one of the changed files is related to the input CSS or JS
|
||||
// config/plugin files, then we need to do a full rebuild because
|
||||
// the theme might have changed.
|
||||
if (resolvedFullRebuildPaths.includes(file)) {
|
||||
rebuildStrategy = 'full'
|
||||
|
||||
// No need to check the rest of the events, because we already know we
|
||||
// need to do a full rebuild.
|
||||
break
|
||||
}
|
||||
|
||||
// Track new and updated files for incremental rebuilds.
|
||||
changedFiles.push({
|
||||
file,
|
||||
extension: path.extname(file).slice(1),
|
||||
} satisfies ChangedContent)
|
||||
}
|
||||
let rebuildStrategy = getRebuildStrategy(files, resolvedFullRebuildPaths)
|
||||
|
||||
// Track the compiled CSS
|
||||
let compiledCss = ''
|
||||
let compiledMap: SourceMap | null = null
|
||||
|
||||
// Scan the entire `base` directory for full rebuilds.
|
||||
if (rebuildStrategy === 'full') {
|
||||
if (rebuildStrategy.kind === 'full') {
|
||||
// Read the new `input`.
|
||||
let input = args['--input']
|
||||
? args['--input'] === '-'
|
||||
|
|
@ -378,9 +380,9 @@ export async function handle(args: Result<ReturnType<typeof options>>) {
|
|||
}
|
||||
|
||||
// Scan changed files only for incremental rebuilds.
|
||||
else if (rebuildStrategy === 'incremental') {
|
||||
else if (rebuildStrategy.kind === 'incremental') {
|
||||
DEBUG && I.start('Scan for candidates')
|
||||
let newCandidates = scanner.scanFiles(changedFiles)
|
||||
let newCandidates = scanner.scanFiles(rebuildStrategy.changedFiles)
|
||||
DEBUG && I.end('Scan for candidates')
|
||||
|
||||
// No new candidates found which means we don't need to write to
|
||||
|
|
@ -490,6 +492,170 @@ export async function handle(args: Result<ReturnType<typeof options>>) {
|
|||
|
||||
let end = process.hrtime.bigint()
|
||||
if (!args['--silent']) eprintln(`Done in ${formatDuration(end - start)}`)
|
||||
|
||||
// Watch for changes, using polling
|
||||
if (args['--watch'] && pollInterval !== false) {
|
||||
let cleanupPollingIndicator = () => {}
|
||||
|
||||
function logPollingMessage(message: string) {
|
||||
if (!args['--silent']) {
|
||||
process.stderr.write('\r\x1B[2K')
|
||||
}
|
||||
|
||||
eprintln(message)
|
||||
|
||||
if (!args['--silent']) {
|
||||
process.stderr.write(`\r\x1B[2K${dim(`Polling for changes…`)}`)
|
||||
}
|
||||
}
|
||||
|
||||
async function fullRebuild(I: Instrumentation) {
|
||||
clearRequireCache(fullRebuildPaths)
|
||||
backupRebuildPaths = fullRebuildPaths.slice()
|
||||
fullRebuildPaths = inputFilePath ? [inputFilePath] : []
|
||||
|
||||
let input = args['--input']
|
||||
? args['--input'] === '-'
|
||||
? await drainStdin()
|
||||
: await fs.readFile(args['--input'], 'utf-8')
|
||||
: css`
|
||||
@import 'tailwindcss';
|
||||
`
|
||||
|
||||
;[compiler, scanner] = await createCompiler(input, I)
|
||||
backupRebuildPaths = fullRebuildPaths.slice()
|
||||
|
||||
DEBUG && I.start('Scan for candidates')
|
||||
let candidates = scanner.scan()
|
||||
DEBUG && I.end('Scan for candidates')
|
||||
|
||||
DEBUG && I.start('Build CSS')
|
||||
let output = compiler.build(candidates)
|
||||
DEBUG && I.end('Build CSS')
|
||||
|
||||
let map: SourceMap | null = null
|
||||
|
||||
if (args['--map']) {
|
||||
DEBUG && I.start('Build Source Map')
|
||||
map = toSourceMap(compiler.buildSourceMap())
|
||||
DEBUG && I.end('Build Source Map')
|
||||
}
|
||||
|
||||
await write(output, map, args, I)
|
||||
}
|
||||
|
||||
if (!args['--silent']) {
|
||||
let restored = false
|
||||
|
||||
function restoreCursor() {
|
||||
if (restored) return
|
||||
restored = true
|
||||
process.stderr.write('\r\x1B[2K\x1B[?25h')
|
||||
process.off('exit', restoreCursor)
|
||||
process.off('SIGINT', onSigint)
|
||||
process.off('SIGTERM', onSigterm)
|
||||
}
|
||||
|
||||
function onSigint() {
|
||||
restoreCursor()
|
||||
process.exit(130)
|
||||
}
|
||||
|
||||
function onSigterm() {
|
||||
restoreCursor()
|
||||
process.exit(143)
|
||||
}
|
||||
|
||||
process.stderr.write('\x1B[?25l')
|
||||
process.on('exit', restoreCursor)
|
||||
process.on('SIGINT', onSigint)
|
||||
process.on('SIGTERM', onSigterm)
|
||||
|
||||
cleanupPollingIndicator = restoreCursor
|
||||
cleanupWatchers.push(async () => cleanupPollingIndicator())
|
||||
}
|
||||
|
||||
cleanupWatchers.push(
|
||||
createPollingWatcher(async () => {
|
||||
using I = new Instrumentation()
|
||||
|
||||
DEBUG && I.start('Scan for candidates')
|
||||
let candidates = scanner.scan()
|
||||
DEBUG && I.end('Scan for candidates')
|
||||
|
||||
let files = scanner.scannedFiles.filter(
|
||||
(file) => file !== args['--output'] && file !== args['--map'],
|
||||
)
|
||||
|
||||
if (files.length <= 0) return
|
||||
|
||||
let start = process.hrtime.bigint()
|
||||
|
||||
let strategy = getRebuildStrategy(files, fullRebuildPaths)
|
||||
|
||||
if (strategy.kind === 'full') {
|
||||
try {
|
||||
await fullRebuild(I)
|
||||
} catch (err) {
|
||||
fullRebuildPaths = backupRebuildPaths
|
||||
|
||||
let message = [red('Error:'), dim('\u250C')]
|
||||
.concat(`${err}`.split('\n').map((line) => `${dim('\u2502')} ${line}`))
|
||||
.concat(dim('\u2514'))
|
||||
.join('\n')
|
||||
|
||||
logPollingMessage(message)
|
||||
}
|
||||
|
||||
if (!args['--silent']) {
|
||||
logPollingMessage(`Done in ${formatDuration(process.hrtime.bigint() - start)}`)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
// No candidates found which means we don't need to write to disk, and
|
||||
// can return early.
|
||||
if (candidates.length <= 0) {
|
||||
if (!args['--silent']) {
|
||||
logPollingMessage(`Done in ${formatDuration(process.hrtime.bigint() - start)}`)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
DEBUG && I.start('Build CSS')
|
||||
let output = compiler.build(candidates)
|
||||
DEBUG && I.end('Build CSS')
|
||||
|
||||
let map: SourceMap | null = null
|
||||
|
||||
if (args['--map']) {
|
||||
DEBUG && I.start('Build Source Map')
|
||||
map = toSourceMap(compiler.buildSourceMap())
|
||||
DEBUG && I.end('Build Source Map')
|
||||
}
|
||||
|
||||
await write(output, map, args, I)
|
||||
|
||||
if (!args['--silent']) {
|
||||
logPollingMessage(`Done in ${formatDuration(process.hrtime.bigint() - start)}`)
|
||||
}
|
||||
}, pollInterval),
|
||||
)
|
||||
|
||||
if (args['--watch'] !== 'always') {
|
||||
process.stdin.on('end', () => {
|
||||
Promise.all(cleanupWatchers.map((fn) => fn())).then(
|
||||
() => process.exit(0),
|
||||
() => process.exit(1),
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
process.stdin.resume()
|
||||
if (!args['--silent']) {
|
||||
process.stderr.write(`\r\x1B[2K${dim(`Polling for changes…`)}`)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function createWatchers(dirs: string[], cb: (files: string[]) => void) {
|
||||
|
|
@ -594,6 +760,56 @@ async function createWatchers(dirs: string[], cb: (files: string[]) => void) {
|
|||
}
|
||||
}
|
||||
|
||||
function getRebuildStrategy(
|
||||
files: string[],
|
||||
fullRebuildPaths: string[],
|
||||
): { kind: 'incremental'; changedFiles: ChangedContent[] } | { kind: 'full' } {
|
||||
let changedFiles: ChangedContent[] = []
|
||||
|
||||
for (let file of files) {
|
||||
// If one of the changed files is related to the input CSS or JS
|
||||
// config/plugin files, then we need to do a full rebuild because
|
||||
// the theme might have changed.
|
||||
if (fullRebuildPaths.includes(file)) {
|
||||
return { kind: 'full' }
|
||||
}
|
||||
|
||||
// Track new and updated files for incremental rebuilds.
|
||||
changedFiles.push({
|
||||
file,
|
||||
extension: path.extname(file).slice(1),
|
||||
} satisfies ChangedContent)
|
||||
}
|
||||
|
||||
return { kind: 'incremental', changedFiles }
|
||||
}
|
||||
|
||||
function createPollingWatcher(cb: () => Promise<void>, pollInterval: number) {
|
||||
let disposed = false
|
||||
let timer: ReturnType<typeof setTimeout> | null = null
|
||||
|
||||
async function poll() {
|
||||
if (disposed) return
|
||||
|
||||
try {
|
||||
await cb()
|
||||
} catch (err) {
|
||||
console.error(err)
|
||||
} finally {
|
||||
if (!disposed) {
|
||||
timer = setTimeout(poll, pollInterval)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
timer = setTimeout(poll, pollInterval)
|
||||
|
||||
return async () => {
|
||||
disposed = true
|
||||
if (timer) clearTimeout(timer)
|
||||
}
|
||||
}
|
||||
|
||||
async function watchDirectories(scanner: Scanner) {
|
||||
let directories = (
|
||||
await Promise.all(
|
||||
|
|
|
|||
|
|
@ -11,7 +11,7 @@ const sharedOptions = {
|
|||
} satisfies Arg
|
||||
|
||||
function buildUsage(command = 'tailwindcss') {
|
||||
return `${command} [--input input.css] [--output output.css] [--watch] [options…]`
|
||||
return `${command} [--input input.css] [--output output.css] [--watch] [--poll=ms] [options…]`
|
||||
}
|
||||
|
||||
function rootHelp({ invalid }: { invalid?: string } = {}) {
|
||||
|
|
|
|||
|
|
@ -153,3 +153,34 @@ it('should be possible to provide multiple types, and convert the value to that
|
|||
}
|
||||
`)
|
||||
})
|
||||
|
||||
it('should be possible to provide boolean or number arguments', () => {
|
||||
let options = {
|
||||
'--poll': {
|
||||
type: 'boolean | number',
|
||||
description: 'Use polling instead of filesystem events when watching',
|
||||
default: false,
|
||||
},
|
||||
} satisfies Arg
|
||||
|
||||
expect(args(options, ['--poll'])).toMatchInlineSnapshot(`
|
||||
{
|
||||
"--poll": true,
|
||||
"_": [],
|
||||
}
|
||||
`)
|
||||
|
||||
expect(args(options, ['--poll=50'])).toMatchInlineSnapshot(`
|
||||
{
|
||||
"--poll": 50,
|
||||
"_": [],
|
||||
}
|
||||
`)
|
||||
|
||||
expect(args(options, ['--poll=foo'])).toMatchInlineSnapshot(`
|
||||
{
|
||||
"--poll": undefined,
|
||||
"_": [],
|
||||
}
|
||||
`)
|
||||
})
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue