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:
Robin Malfait 2026-07-01 21:25:26 +02:00 • committed by GitHub
parent 056a155072
commit 39656f7d9a
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
8 changed files with 418 additions and 54 deletions

View file

@ -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

View file

@ -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

View file

@ -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 {

View file

@ -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 {

View file

@ -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",
{

View file

@ -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(

View file

@ -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 } = {}) {

View file

@ -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,
"_": [],
}
`)
})