This commit is contained in:
Som Samantray 2026-09-25 16:38:34 +05:30 • committed by GitHub
commit 4535cda700
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
2 changed files with 344 additions and 167 deletions

View file

@ -375,6 +375,131 @@ 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`<div class="underline"></div>`,
'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<boolean>((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`<div class="underline flex"></div>`)
await fs.expectFileToContain('dist/out.css', [candidate`flex`])
},
)
// 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`<div class="underline"></div>`,
// 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`
<div
class="content-['initial']"
></div>
`,
},
},
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`<div class="content-['changed']"></div>`,
)
await fs.expectFileToContain('dist/out.css', [candidate`content-['changed']`])
},
)
// 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`<div class="underline"></div>`,
// 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',
{

View file

@ -28,6 +28,14 @@ const css = String.raw
const DEBUG = env.DEBUG
const DEFAULT_POLL_INTERVAL_MS = 250
// 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 {
'--input': {
@ -334,154 +342,162 @@ export async function handle(args: Result<ReturnType<typeof options>>) {
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
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)')
using I = new Instrumentation()
DEBUG && I.start('[@tailwindcss/cli] (watcher)')
// Re-compile the input
let start = process.hrtime.bigint()
// Re-compile the input
let start = process.hrtime.bigint()
let resolvedFullRebuildPaths = fullRebuildPaths
let rebuildStrategy = getRebuildStrategy(files, resolvedFullRebuildPaths)
let resolvedFullRebuildPaths = fullRebuildPaths
let rebuildStrategy = getRebuildStrategy(files, resolvedFullRebuildPaths)
// Track the compiled CSS
let compiledCss = ''
let compiledMap: SourceMap | null = null
// 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)
// 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()
// 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] : []
// 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)
// 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()
// 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')
// 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')
// 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')
// 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)
cleanupWatchers.push(newCleanupFunction)
// Re-compile the CSS
DEBUG && I.start('Build CSS')
compiledCss = compiler.build(candidates)
DEBUG && I.end('Build CSS')
// 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')
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
}
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')
}
}
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)}`)
}
// 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')
}
}
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)}`)
}
}),
},
fullRebuildPaths,
),
)
// Abort the watcher if `stdin` is closed to avoid zombie processes. You can
@ -693,9 +709,22 @@ async function loadWatcher(): Promise<typeof import('@parcel/watcher')> {
}
}
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, 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
// of whether or not they are for the directory that is duplicated.
@ -747,44 +776,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') {
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
}
// Track the changed file.
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
}
// 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: watchIgnoreFor(dir, allRequestedDirs) },
)
// Ensure we cleanup the watcher when we're done.
watchers.add(unsubscribe)
@ -847,6 +880,25 @@ function createPollingWatcher(cb: () => Promise<void>, 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(