From 9c3d968fae84246dd1b0ced4b97870b96d92ec94 Mon Sep 17 00:00:00 2001 From: SomSamantray <> Date: Wed, 5 Aug 2026 20:46:51 +0530 Subject: [PATCH 1/4] fix(cli): exclude noise directories from --watch file watcher Prevent the CLI's file watcher from recursing into node_modules, .git, .hg, and .svn by passing an ignore option to @parcel/watcher's subscribe(). Previously these directories were watched with no filtering, causing spurious rebuild cycles and contributing to the reported CPU-pegging hang on cold Watchman starts against large, uncached trees. Fixes #17246 --- .../src/commands/build/index.ts | 79 +++++++++++-------- 1 file changed, 45 insertions(+), 34 deletions(-) diff --git a/packages/@tailwindcss-cli/src/commands/build/index.ts b/packages/@tailwindcss-cli/src/commands/build/index.ts index c85eda28c0d9..c33f7355b96b 100644 --- a/packages/@tailwindcss-cli/src/commands/build/index.ts +++ b/packages/@tailwindcss-cli/src/commands/build/index.ts @@ -28,6 +28,13 @@ const css = String.raw const DEBUG = env.DEBUG const DEFAULT_POLL_INTERVAL_MS = 250 +// Directories that should never be watched, regardless of which project +// directories are being watched. Watching these can cause excessive CPU +// usage or hangs on file watcher backends (e.g. Watchman) when they contain +// large or frequently-changing trees, and Tailwind never needs to react to +// changes inside them. +const DEFAULT_WATCH_IGNORE = ['**/node_modules/**', '**/.git/**', '**/.hg/**', '**/.svn/**'] + export function options() { return { '--input': { @@ -747,44 +754,48 @@ async function createWatchers(dirs: string[], cb: (files: string[]) => void) { // Setup a watcher for every directory. for (let dir of dirs) { - let { unsubscribe } = await watcher.subscribe(dir, async (err, events) => { - // Whenever an error occurs we want to let the user know about it but we - // want to keep watching for changes. - if (err) { - console.error(err) - return - } + let { unsubscribe } = await watcher.subscribe( + dir, + async (err, events) => { + // Whenever an error occurs we want to let the user know about it but we + // want to keep watching for changes. + if (err) { + console.error(err) + return + } - await Promise.all( - events.map(async (event) => { - // When a file is deleted, a rebuild should be triggered such that we - // can figure out whether this file must trigger a fresh build or not. - // - // If it must trigger a fresh build, then we will temporarily end up - // in a broken state, but an error will be shown to the user. Once the - // user resolves the issue, the CLI will recover. - if (event.type === 'delete') { - files.add(event.path) - return - } + await Promise.all( + events.map(async (event) => { + // When a file is deleted, a rebuild should be triggered such that we + // can figure out whether this file must trigger a fresh build or not. + // + // If it must trigger a fresh build, then we will temporarily end up + // in a broken state, but an error will be shown to the user. Once the + // user resolves the issue, the CLI will recover. + if (event.type === 'delete') { + files.add(event.path) + return + } - // Ignore directory changes. We only care about file changes - let stats: Stats | null = null - try { - stats = await fs.lstat(event.path) - } catch {} - if (!stats?.isFile() && !stats?.isSymbolicLink()) { - return - } + // Ignore directory changes. We only care about file changes + let stats: Stats | null = null + try { + stats = await fs.lstat(event.path) + } catch {} + if (!stats?.isFile() && !stats?.isSymbolicLink()) { + return + } - // Track the changed file. - files.add(event.path) - }), - ) + // Track the changed file. + files.add(event.path) + }), + ) - // Handle the tracked files at some point in the future. - await enqueueCallback() - }) + // Handle the tracked files at some point in the future. + await enqueueCallback() + }, + { ignore: DEFAULT_WATCH_IGNORE }, + ) // Ensure we cleanup the watcher when we're done. watchers.add(unsubscribe) From d5b8ac915cd1039a1ca53eea49308ea456511b54 Mon Sep 17 00:00:00 2001 From: SomSamantray <> Date: Wed, 5 Aug 2026 20:51:56 +0530 Subject: [PATCH 2/4] test(cli): verify watch mode ignores node_modules and .git Adds coverage for the ignore-option fix: asserts that changes inside node_modules or .git no longer trigger a watcher rebuild cycle, while confirming the watcher still reacts to real source changes. Could not be executed in this environment (requires a full monorepo build producing dist/ tarballs, which needs the native Oxide crate); the underlying mechanism was independently verified against the installed @parcel/watcher@2.6.0 binary directly. --- integrations/cli/index.test.ts | 35 ++++++++++++++++++++++++++++++++++ 1 file changed, 35 insertions(+) diff --git a/integrations/cli/index.test.ts b/integrations/cli/index.test.ts index 310086802779..876995b7796c 100644 --- a/integrations/cli/index.test.ts +++ b/integrations/cli/index.test.ts @@ -375,6 +375,41 @@ describe.each([ }, ) + // https://github.com/tailwindlabs/tailwindcss/issues/17246 + test( + 'watch mode does not rebuild for changes inside node_modules or .git', + { + fs: { + 'package.json': json`{}`, + 'index.html': html`
`, + 'src/index.css': css` @import 'tailwindcss/utilities'; `, + 'node_modules/some-dep/index.js': js` module.exports = {} `, + '.git/HEAD': txt`ref: refs/heads/main`, + }, + }, + async ({ fs, spawn, expect }) => { + let process = await spawn(`${command} --input src/index.css --output dist/out.css --watch`) + await process.onStderr((m) => m.includes('Done in')) + process.flush() + + await fs.write('node_modules/some-dep/index.js', js`module.exports = { touched: true }`) + await fs.write('.git/HEAD', txt`ref: refs/heads/other`) + + // A watcher rebuild cycle (real or a no-op early return) always logs + // "Done in", so if the watcher incorrectly reacted to either change + // above, this would resolve. It must not. + let sawRebuild = await Promise.race([ + process.onStderr((m) => m.includes('Done in')).then(() => true), + new Promise((resolve) => setTimeout(() => resolve(false), 1000)), + ]) + expect(sawRebuild).toBe(false) + + // Sanity check: the watcher is still alive and reacts to real changes. + await fs.write('index.html', html`
`) + await fs.expectFileToContain('dist/out.css', [candidate`flex`]) + }, + ) + test( 'watch mode with polling', { From 45921ede3c3666c810abd9f05f5b35bc6f7adbab Mon Sep 17 00:00:00 2001 From: SomSamantray <> Date: Wed, 5 Aug 2026 21:04:33 +0530 Subject: [PATCH 3/4] fix(cli): don't ignore noise dirs that contain an explicit @source Two independent code-review passes (correctness and reliability) found that a blanket node_modules/.git ignore on every watcher.subscribe() call can silently break watch-mode rebuilds for an explicit @source path living inside node_modules, if that path's own watch root gets collapsed into a broader ancestor by the existing dedup step -- since @parcel/watcher matches ignore globs relative to the watched root. watchIgnoreFor() now skips ignoring a noise segment for a given directory when another originally-requested directory got collapsed into it and lives inside that segment. Verified empirically against the installed @parcel/watcher@2.6.0 binary directly (0 events before this fix for the nested case, 2 after -- matching pre-regression behavior), and with isolated unit tests of the pure ignore-list logic. Also fixes a test timeout that wasn't scaled for Windows like the rest of the suite (per testing-reviewer), and adds e2e coverage for the nested-@source-inside-node_modules scenario. --- integrations/cli/index.test.ts | 38 +++++++++++++++++ .../src/commands/build/index.ts | 41 +++++++++++++++---- 2 files changed, 72 insertions(+), 7 deletions(-) diff --git a/integrations/cli/index.test.ts b/integrations/cli/index.test.ts index 876995b7796c..a96eee321324 100644 --- a/integrations/cli/index.test.ts +++ b/integrations/cli/index.test.ts @@ -410,6 +410,44 @@ describe.each([ }, ) + // https://github.com/tailwindlabs/tailwindcss/issues/17246 + test( + 'watch mode still rebuilds for an explicit @source nested inside node_modules, even when its watch root is collapsed into a broader ancestor', + { + fs: { + 'package.json': json`{}`, + 'src/index.css': css` + @import 'tailwindcss/utilities'; + @source '../**/*.html'; + @source '../node_modules/my-lib/src/*.html'; + `, + 'index.html': html`
`, + // This lives inside node_modules, but is explicitly opted back in via + // @source above. The broader `../**/*.html` source also covers the + // project root, so this directory gets collapsed into it by + // createWatchers' dedup step -- the ignore filter must not apply to + // node_modules for that collapsed root. + 'node_modules/my-lib/src/index.html': html` +
+ `, + }, + }, + async ({ fs, spawn }) => { + let process = await spawn(`${command} --input src/index.css --output dist/out.css --watch`) + await process.onStderr((m) => m.includes('Done in')) + + await fs.expectFileToContain('dist/out.css', [candidate`content-['initial']`]) + + await fs.write( + 'node_modules/my-lib/src/index.html', + html`
`, + ) + await fs.expectFileToContain('dist/out.css', [candidate`content-['changed']`]) + }, + ) + test( 'watch mode with polling', { diff --git a/packages/@tailwindcss-cli/src/commands/build/index.ts b/packages/@tailwindcss-cli/src/commands/build/index.ts index c33f7355b96b..54b5ce5b07a2 100644 --- a/packages/@tailwindcss-cli/src/commands/build/index.ts +++ b/packages/@tailwindcss-cli/src/commands/build/index.ts @@ -28,12 +28,13 @@ const css = String.raw const DEBUG = env.DEBUG const DEFAULT_POLL_INTERVAL_MS = 250 -// Directories that should never be watched, regardless of which project -// directories are being watched. Watching these can cause excessive CPU -// usage or hangs on file watcher backends (e.g. Watchman) when they contain -// large or frequently-changing trees, and Tailwind never needs to react to -// changes inside them. -const DEFAULT_WATCH_IGNORE = ['**/node_modules/**', '**/.git/**', '**/.hg/**', '**/.svn/**'] +// Directory segments that should not be watched by default, regardless of +// which project directories are being watched. Watching these can cause +// excessive CPU usage or hangs on file watcher backends (e.g. Watchman) when +// they contain large or frequently-changing trees, and Tailwind normally +// never needs to react to changes inside them. See `watchIgnoreFor` for the +// one exception: an explicit `@source` pointing inside one of these. +const DEFAULT_WATCH_IGNORE_SEGMENTS = ['node_modules', '.git', '.hg', '.svn'] export function options() { return { @@ -703,6 +704,13 @@ async function loadWatcher(): Promise { async function createWatchers(dirs: string[], cb: (files: string[]) => void) { let watcher = await loadWatcher() + // Keep every originally-requested directory before the dedup step below + // collapses child directories into an already-watched parent. We need + // this to detect when an explicit source directory (e.g. an `@source` + // pointing inside `node_modules`) ends up nested under a broader watched + // root, so we know not to ignore that noise directory for that root. + let allRequestedDirs = dirs.slice() + // Remove any directories that are children of an already watched directory. // If we don't we may not get notified of certain filesystem events regardless // of whether or not they are for the directory that is duplicated. @@ -794,7 +802,7 @@ async function createWatchers(dirs: string[], cb: (files: string[]) => void) { // Handle the tracked files at some point in the future. await enqueueCallback() }, - { ignore: DEFAULT_WATCH_IGNORE }, + { ignore: watchIgnoreFor(dir, allRequestedDirs) }, ) // Ensure we cleanup the watcher when we're done. @@ -858,6 +866,25 @@ function createPollingWatcher(cb: () => Promise, pollInterval: number) { } } +// Compute the default watch-ignore glob list for `dir`, one of the +// (already deduped) directories being watched. Skips any noise segment +// (`node_modules`, `.git`, etc.) that contains another originally-requested +// directory nested inside it — e.g. an explicit `@source` pointing inside +// `node_modules` whose own watch root got collapsed into this broader `dir` +// by the dedup step in `createWatchers`. Ignoring that segment for `dir` +// would otherwise silently stop the watcher from picking up changes to that +// explicitly-configured source, since `@parcel/watcher` matches `ignore` +// globs relative to the watched root — the segment only needs to be +// preserved for the root that actually ended up watching that subtree. +function watchIgnoreFor(dir: string, allRequestedDirs: string[]): string[] { + return DEFAULT_WATCH_IGNORE_SEGMENTS.filter((segment) => { + let marker = `/${segment}/` + return !allRequestedDirs.some( + (other) => other !== dir && other.startsWith(`${dir}/`) && `${other}/`.includes(marker), + ) + }).map((segment) => `**/${segment}/**`) +} + async function watchDirectories(scanner: Scanner) { let directories = ( await Promise.all( From 269b78ac69a7d20d19f1c3c2361594551eafdcac Mon Sep 17 00:00:00 2001 From: SomSamantray <> Date: Wed, 5 Aug 2026 22:13:24 +0530 Subject: [PATCH 4/4] fix(cli): don't ignore noise dirs containing a tracked dependency Greptile review on #20388 found that the ignore-list exemption only considered scanner.normalizedSources (explicit @source paths), not fullRebuildPaths -- compiler dependencies discovered via @import (e.g. tailwindcss itself, or any imported CSS package resolved from node_modules). If such a dependency's directory was nested under a watched root with no other reason to be exempted, editing it would no longer trigger a rebuild. createWatchers() now also accepts the current fullRebuildPaths so watchIgnoreFor() can exempt a noise segment for a root when a tracked dependency lives inside it, the same way it already does for explicit source directories. Verified with isolated unit tests of the ignore logic covering both the reported regression and a control case (unrelated dependency paths must not spuriously exempt anything), plus new e2e coverage in integrations/cli/index.test.ts. --- integrations/cli/index.test.ts | 52 +++ .../src/commands/build/index.ts | 312 +++++++++--------- 2 files changed, 215 insertions(+), 149 deletions(-) diff --git a/integrations/cli/index.test.ts b/integrations/cli/index.test.ts index a96eee321324..5d9daaa75501 100644 --- a/integrations/cli/index.test.ts +++ b/integrations/cli/index.test.ts @@ -448,6 +448,58 @@ describe.each([ }, ) + // https://github.com/tailwindlabs/tailwindcss/pull/20388#discussion (Greptile) + test( + 'watch mode still rebuilds when a tracked @import dependency inside node_modules changes', + { + fs: { + 'package.json': json`{}`, + 'src/index.css': css` + @import 'tailwindcss/utilities'; + @import '../node_modules/my-base-styles/base.css'; + `, + 'index.html': html`
`, + // Not covered by any @source -- this is a plain @import dependency, + // tracked via `onDependency`/`fullRebuildPaths` rather than + // `scanner.normalizedSources`. It must still be exempted from the + // node_modules ignore filter for the collapsed project root. + 'node_modules/my-base-styles/base.css': css` + .imported-marker { + color: red; + } + `, + }, + }, + async ({ fs, spawn }) => { + let process = await spawn(`${command} --input src/index.css --output dist/out.css --watch`) + await process.onStderr((m) => m.includes('Done in')) + + await fs.expectFileToContain('dist/out.css', [ + css` + .imported-marker { + color: red; + } + `, + ]) + + await fs.write( + 'node_modules/my-base-styles/base.css', + css` + .imported-marker { + color: blue; + } + `, + ) + await fs.expectFileToContain('dist/out.css', [ + css` + .imported-marker { + color: blue; + } + `, + ]) + }, + ) + test( 'watch mode with polling', { diff --git a/packages/@tailwindcss-cli/src/commands/build/index.ts b/packages/@tailwindcss-cli/src/commands/build/index.ts index 54b5ce5b07a2..140232423066 100644 --- a/packages/@tailwindcss-cli/src/commands/build/index.ts +++ b/packages/@tailwindcss-cli/src/commands/build/index.ts @@ -342,154 +342,162 @@ export async function handle(args: Result>) { await handleError(() => loadWatcher()) cleanupWatchers.push( - await createWatchers(await watchDirectories(scanner), async function handle(files) { - try { - // If the only change happened to the output file, then we don't want to - // trigger a rebuild because that will result in an infinite loop. - if (files.length === 1 && files[0] === args['--output']) return - - using I = new Instrumentation() - DEBUG && I.start('[@tailwindcss/cli] (watcher)') - - // Re-compile the input - let start = process.hrtime.bigint() - - let resolvedFullRebuildPaths = fullRebuildPaths - 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.kind === 'full') { - // Read the new `input`. - let input = args['--input'] - ? args['--input'] === '-' - ? await drainStdin() - : await fs.readFile(args['--input'], 'utf-8') - : css` - @import 'tailwindcss'; - ` - clearRequireCache(resolvedFullRebuildPaths) - - // Track current rebuild paths in case something goes wrong when - // performing a full rebuild. - backupRebuildPaths = fullRebuildPaths.slice() - - // The `inputFilePath`, if provided, will be the only known full - // rebuild path before the compiler is re-created. - fullRebuildPaths = inputFilePath ? [inputFilePath] : [] - - // Create a new compiler, given the new `input` - ;[compiler, scanner] = await createCompiler(input, I) - - // Succesfully created a new compiler, so the `fullRebuildPaths` - // will be updated. If other errors occur, we should be able to - // restore the paths unconditionally. - backupRebuildPaths = fullRebuildPaths.slice() - - // Scan the directory for candidates - DEBUG && I.start('Scan for candidates') - let candidates = scanner.scan() - DEBUG && I.end('Scan for candidates') - - // Setup new watchers - DEBUG && I.start('Setup new watchers') - let newCleanupFunction = await createWatchers(await watchDirectories(scanner), handle) - DEBUG && I.end('Setup new watchers') - - // Clear old watchers - DEBUG && I.start('Cleanup old watchers') - await Promise.all(cleanupWatchers.splice(0).map((cleanup) => cleanup())) - DEBUG && I.end('Cleanup old watchers') - - cleanupWatchers.push(newCleanupFunction) - - // Re-compile the CSS - DEBUG && I.start('Build CSS') - compiledCss = compiler.build(candidates) - DEBUG && I.end('Build CSS') - - if (args['--map']) { - DEBUG && I.start('Build Source Map') - compiledMap = toSourceMap(compiler.buildSourceMap()) - DEBUG && I.end('Build Source Map') + await createWatchers( + await watchDirectories(scanner), + async function handle(files) { + try { + // If the only change happened to the output file, then we don't want to + // trigger a rebuild because that will result in an infinite loop. + if (files.length === 1 && files[0] === args['--output']) return + + using I = new Instrumentation() + DEBUG && I.start('[@tailwindcss/cli] (watcher)') + + // Re-compile the input + let start = process.hrtime.bigint() + + let resolvedFullRebuildPaths = fullRebuildPaths + 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.kind === 'full') { + // Read the new `input`. + let input = args['--input'] + ? args['--input'] === '-' + ? await drainStdin() + : await fs.readFile(args['--input'], 'utf-8') + : css` + @import 'tailwindcss'; + ` + clearRequireCache(resolvedFullRebuildPaths) + + // Track current rebuild paths in case something goes wrong when + // performing a full rebuild. + backupRebuildPaths = fullRebuildPaths.slice() + + // The `inputFilePath`, if provided, will be the only known full + // rebuild path before the compiler is re-created. + fullRebuildPaths = inputFilePath ? [inputFilePath] : [] + + // Create a new compiler, given the new `input` + ;[compiler, scanner] = await createCompiler(input, I) + + // Succesfully created a new compiler, so the `fullRebuildPaths` + // will be updated. If other errors occur, we should be able to + // restore the paths unconditionally. + backupRebuildPaths = fullRebuildPaths.slice() + + // Scan the directory for candidates + DEBUG && I.start('Scan for candidates') + let candidates = scanner.scan() + DEBUG && I.end('Scan for candidates') + + // Setup new watchers + DEBUG && I.start('Setup new watchers') + let newCleanupFunction = await createWatchers( + await watchDirectories(scanner), + handle, + fullRebuildPaths, + ) + DEBUG && I.end('Setup new watchers') + + // Clear old watchers + DEBUG && I.start('Cleanup old watchers') + await Promise.all(cleanupWatchers.splice(0).map((cleanup) => cleanup())) + DEBUG && I.end('Cleanup old watchers') + + cleanupWatchers.push(newCleanupFunction) + + // Re-compile the CSS + DEBUG && I.start('Build CSS') + compiledCss = compiler.build(candidates) + DEBUG && I.end('Build CSS') + + if (args['--map']) { + DEBUG && I.start('Build Source Map') + compiledMap = toSourceMap(compiler.buildSourceMap()) + DEBUG && I.end('Build Source Map') + } } - } - // Scan changed files only for incremental rebuilds. - else if (rebuildStrategy.kind === 'incremental') { - DEBUG && I.start('Scan for candidates') - 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 - // disk, and can return early. - if (newCandidates.length <= 0) { - let end = process.hrtime.bigint() - if (!args['--silent']) eprintln(`Done in ${formatDuration(end - start)}`) - return + // Scan changed files only for incremental rebuilds. + else if (rebuildStrategy.kind === 'incremental') { + DEBUG && I.start('Scan for candidates') + 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 + // disk, and can return early. + if (newCandidates.length <= 0) { + let end = process.hrtime.bigint() + if (!args['--silent']) eprintln(`Done in ${formatDuration(end - start)}`) + return + } + + DEBUG && I.start('Build CSS') + compiledCss = compiler.build(newCandidates) + DEBUG && I.end('Build CSS') + + if (args['--map']) { + DEBUG && I.start('Build Source Map') + compiledMap = toSourceMap(compiler.buildSourceMap()) + DEBUG && I.end('Build Source Map') + } } - DEBUG && I.start('Build CSS') - compiledCss = compiler.build(newCandidates) - DEBUG && I.end('Build CSS') + await write(compiledCss, compiledMap, args, I) - if (args['--map']) { - DEBUG && I.start('Build Source Map') - compiledMap = toSourceMap(compiler.buildSourceMap()) - DEBUG && I.end('Build Source Map') - } - } + let end = process.hrtime.bigint() + if (!args['--silent']) eprintln(`Done in ${formatDuration(end - start)}`) + } catch (err) { + // It's important that we perform a full rebuild when any of the + // dependencies tracked in `fullRebuildPaths` has been changed. + // + // If we remove one of those files, then a subsequent build will be + // triggered, but it will fail because the dependency is gone. The + // compiler itself will be in a broken state and won't be able to + // register any dependencies therefore we want to restore all the + // dependencies from before. If we don't do that, then we won't be + // able to recover from a bug in a transitive dependency. + // + // E.g.: + // ```css + // /* input.css — known full rebuild path */ + // @import 'tailwindcss'; + // @config "./tailwind.config.js"; + // ``` + // + // ```js + // // tailwind.config.js + // const theme = require('./my-theme.js'); + // + // module.exports = { + // theme + // } + // ``` + // In this case `my-theme.js` is a transitive dependency of + // `input.css` via `tailwind.config.js`. Removing `my-theme.js` will + // result in an error, restoring `my-theme.js` should trigger a + // fresh build even though the compiler didn't restore. + // + // Once the build error is fixed, a fresh full rebuild will happen + // which in turn will fixup the full rebuild paths. + fullRebuildPaths = backupRebuildPaths - await write(compiledCss, compiledMap, args, I) - - let end = process.hrtime.bigint() - if (!args['--silent']) eprintln(`Done in ${formatDuration(end - start)}`) - } catch (err) { - // It's important that we perform a full rebuild when any of the - // dependencies tracked in `fullRebuildPaths` has been changed. - // - // If we remove one of those files, then a subsequent build will be - // triggered, but it will fail because the dependency is gone. The - // compiler itself will be in a broken state and won't be able to - // register any dependencies therefore we want to restore all the - // dependencies from before. If we don't do that, then we won't be - // able to recover from a bug in a transitive dependency. - // - // E.g.: - // ```css - // /* input.css — known full rebuild path */ - // @import 'tailwindcss'; - // @config "./tailwind.config.js"; - // ``` - // - // ```js - // // tailwind.config.js - // const theme = require('./my-theme.js'); - // - // module.exports = { - // theme - // } - // ``` - // In this case `my-theme.js` is a transitive dependency of - // `input.css` via `tailwind.config.js`. Removing `my-theme.js` will - // result in an error, restoring `my-theme.js` should trigger a - // fresh build even though the compiler didn't restore. - // - // Once the build error is fixed, a fresh full rebuild will happen - // which in turn will fixup the full rebuild paths. - fullRebuildPaths = backupRebuildPaths - - // Catch any errors and print them to stderr, but don't exit the process - // and keep watching. - eprintln(formatError(err)) - - let end = process.hrtime.bigint() - if (!args['--silent']) eprintln(`Done in ${formatDuration(end - start)}`) - } - }), + // Catch any errors and print them to stderr, but don't exit the process + // and keep watching. + eprintln(formatError(err)) + + let end = process.hrtime.bigint() + if (!args['--silent']) eprintln(`Done in ${formatDuration(end - start)}`) + } + }, + fullRebuildPaths, + ), ) // Abort the watcher if `stdin` is closed to avoid zombie processes. You can @@ -701,15 +709,21 @@ async function loadWatcher(): Promise { } } -async function createWatchers(dirs: string[], cb: (files: string[]) => void) { +async function createWatchers( + dirs: string[], + cb: (files: string[]) => void, + extraWatchPaths: string[] = [], +) { let watcher = await loadWatcher() - // Keep every originally-requested directory before the dedup step below - // collapses child directories into an already-watched parent. We need - // this to detect when an explicit source directory (e.g. an `@source` - // pointing inside `node_modules`) ends up nested under a broader watched - // root, so we know not to ignore that noise directory for that root. - let allRequestedDirs = dirs.slice() + // Keep every originally-requested directory, plus any tracked compiler + // dependency path (e.g. an `@import`-resolved file inside `node_modules`, + // such as `tailwindcss` itself), before the dedup step below collapses + // child directories into an already-watched parent. We need this to + // detect when an explicit source or dependency file ends up nested under + // a broader watched root, so we know not to ignore that noise directory + // for that root -- otherwise we'd never observe changes to it again. + let allRequestedDirs = dirs.concat(extraWatchPaths) // Remove any directories that are children of an already watched directory. // If we don't we may not get notified of certain filesystem events regardless