Skip to content
Open
Show file tree
Hide file tree
Changes from 3 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
73 changes: 73 additions & 0 deletions integrations/cli/index.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -375,6 +375,79 @@ 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']`])
},
)

test(
'watch mode with polling',
{
Expand Down
106 changes: 72 additions & 34 deletions packages/@tailwindcss-cli/src/commands/build/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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': {
Expand Down Expand Up @@ -696,6 +704,13 @@ async function loadWatcher(): Promise<typeof import('@parcel/watcher')> {
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.
Expand Down Expand Up @@ -747,44 +762,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: watchIgnoreFor(dir, allRequestedDirs) },
Comment thread
greptile-apps[bot] marked this conversation as resolved.
)

// Ensure we cleanup the watcher when we're done.
watchers.add(unsubscribe)
Expand Down Expand Up @@ -847,6 +866,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),
Comment on lines +895 to +897

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Dependency exemption restores noisy watches

When a normal Tailwind import or another compiler dependency resolves inside node_modules, watchIgnoreFor removes the node_modules ignore glob from the entire project-root subscription. Unrelated file events anywhere in that directory then reach the callback and initiate rebuild processing, restoring the spurious rebuilds and high CPU usage this change is intended to prevent.

Knowledge Base Used: Build tool integrations

Note: If this suggestion doesn't match your team's coding style, reply to this and let me know. I'll remember it for next time!

)
}).map((segment) => `**/${segment}/**`)
}

async function watchDirectories(scanner: Scanner) {
let directories = (
await Promise.all(
Expand Down