Skip to content

Commit 1209165

Browse files
committed
Fix overlapping CLI watch rebuilds
1 parent 9f451ee commit 1209165

5 files changed

Lines changed: 388 additions & 16 deletions

File tree

integrations/cli/index.test.ts

Lines changed: 74 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -409,6 +409,80 @@ describe.each([
409409
},
410410
)
411411

412+
test(
413+
'watch mode keeps the newest CSS when a rebuild is already running',
414+
{
415+
fs: {
416+
'package.json': json`{}`,
417+
'pnpm-workspace.yaml': yaml` packages:
418+
- project-a `,
419+
'project-a/package.json': json`
420+
{
421+
"dependencies": {
422+
"tailwindcss": "workspace:^",
423+
"@tailwindcss/cli": "workspace:^"
424+
}
425+
}
426+
`,
427+
'project-a/index.html': html`<div class="from-a from-b"></div>`,
428+
'project-a/src/index.css': css`@import 'tailwindcss/utilities';`,
429+
'project-a/plugin-a.mjs': js`
430+
import fs from 'node:fs/promises'
431+
432+
await fs.writeFile(new URL('./a-started', import.meta.url), 'started')
433+
await new Promise((resolve) => setTimeout(resolve, 2_000))
434+
await fs.appendFile(new URL('./build-order', import.meta.url), 'A')
435+
436+
export default function ({ addUtilities }) {
437+
addUtilities({ '.from-a': { color: 'red' } })
438+
}
439+
`,
440+
'project-a/plugin-b.mjs': js`
441+
import fs from 'node:fs/promises'
442+
443+
await fs.appendFile(new URL('./build-order', import.meta.url), 'B')
444+
445+
export default function ({ addUtilities }) {
446+
addUtilities({ '.from-b': { color: 'green' } })
447+
}
448+
`,
449+
},
450+
},
451+
async ({ root, fs, spawn, expect }) => {
452+
let process = await spawn(`${command} --input src/index.css --output dist/out.css --watch`, {
453+
cwd: path.join(root, 'project-a'),
454+
})
455+
await process.onStderr((message) => message.includes('Done in'))
456+
457+
await fs.write(
458+
'project-a/src/index.css',
459+
css`
460+
@import 'tailwindcss/utilities';
461+
@plugin '../plugin-a.mjs';
462+
`,
463+
)
464+
await retryAssertion(async () => {
465+
expect(await fs.read('project-a/a-started')).toBe('started')
466+
})
467+
468+
await fs.write(
469+
'project-a/src/index.css',
470+
css`
471+
@import 'tailwindcss/utilities';
472+
@plugin '../plugin-b.mjs';
473+
`,
474+
)
475+
476+
await process.onStderr((message) => message.includes('Done in'))
477+
await process.onStderr((message) => message.includes('Done in'))
478+
479+
expect(await fs.read('project-a/build-order')).toBe('AB')
480+
await fs.expectFileToContain('project-a/dist/out.css', [candidate`from-b`])
481+
await fs.expectFileNotToContain('project-a/dist/out.css', [candidate`from-a`])
482+
},
483+
{ skip: kind !== 'CLI' },
484+
)
485+
412486
test(
413487
"watch mode with unknown @source paths shouldn't crash on Windows",
414488
{
Lines changed: 68 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,68 @@
1+
import { expect, it } from 'vitest'
2+
import { serializeBatches } from '../../utils/serial-batches'
3+
import { createWatchers } from './index'
4+
5+
type WatchCallback = (
6+
error: Error | null,
7+
events: { type: 'delete'; path: string }[],
8+
) => Promise<void>
9+
10+
function fakeWatcher() {
11+
let callbacks: WatchCallback[] = []
12+
return {
13+
callbacks,
14+
watcher: {
15+
async subscribe(_directory: string, callback: WatchCallback) {
16+
callbacks.push(callback)
17+
return { unsubscribe() {} }
18+
},
19+
},
20+
}
21+
}
22+
23+
function nextTask() {
24+
return new Promise((resolve) => setTimeout(resolve, 0))
25+
}
26+
27+
it('uses one rebuild queue across watcher generations', async () => {
28+
let releaseFirst!: () => void
29+
let firstCanFinish = new Promise<void>((resolve) => (releaseFirst = resolve))
30+
let calls: string[][] = []
31+
let queue = serializeBatches<string>(async (files) => {
32+
calls.push(files)
33+
if (calls.length === 1) await firstCanFinish
34+
})
35+
let first = fakeWatcher()
36+
let second = fakeWatcher()
37+
38+
let oldGeneration = await createWatchers(['/old'], async () => {}, queue, first.watcher as any)
39+
await first.callbacks[0](null, [{ type: 'delete', path: 'old-change' }])
40+
await nextTask()
41+
expect(calls).toEqual([['old-change']])
42+
43+
let newGeneration = await createWatchers(['/new'], async () => {}, queue, second.watcher as any)
44+
await second.callbacks[0](null, [{ type: 'delete', path: 'new-change' }])
45+
await nextTask()
46+
expect(calls).toEqual([['old-change']])
47+
48+
releaseFirst()
49+
await queue.close()
50+
expect(calls).toEqual([['old-change'], ['new-change']])
51+
52+
await Promise.all([oldGeneration.cleanup(), newGeneration.cleanup()])
53+
})
54+
55+
it('flushes a collected event when shutdown cancels its debounce timer', async () => {
56+
let calls: string[][] = []
57+
let queue = serializeBatches<string>(async (files) => {
58+
calls.push(files)
59+
})
60+
let fake = fakeWatcher()
61+
let generation = await createWatchers(['/watch'], async () => {}, queue, fake.watcher as any)
62+
63+
await fake.callbacks[0](null, [{ type: 'delete', path: 'last-change' }])
64+
await generation.cleanup()
65+
await queue.close()
66+
67+
expect(calls).toEqual([['last-change']])
68+
})

packages/@tailwindcss-cli/src/commands/build/index.ts

Lines changed: 50 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@ import {
2222
relative,
2323
wordWrap,
2424
} from '../../utils/renderer'
25+
import { serializeBatches, type SerialBatches } from '../../utils/serial-batches'
2526
import { drainStdin, outputFile } from './utils'
2627

2728
const css = String.raw
@@ -326,15 +327,25 @@ export async function handle(args: Result<ReturnType<typeof options>>) {
326327

327328
let [compiler, scanner] = await handleError(() => createCompiler(input, I))
328329
let cleanupWatchers: (() => Promise<void>)[] = []
330+
let finishInitialBuild!: () => void
331+
let initialBuildFinished = new Promise<void>((resolve) => (finishInitialBuild = resolve))
332+
let eventBatches: SerialBatches<string> | null = null
333+
let eventHandler: ((files: string[]) => Promise<void>) | null = null
329334

330335
// Watch for changes
331336
if (args['--watch'] && pollInterval === false) {
332337
// Ensure the file watcher can be loaded before setting up any watchers,
333338
// such that we can present a helpful error message if needed.
334339
await handleError(() => loadWatcher())
335340

336-
cleanupWatchers.push(
337-
await createWatchers(await watchDirectories(scanner), async function handle(files) {
341+
eventBatches = serializeBatches(
342+
(files) => eventHandler!(files),
343+
initialBuildFinished,
344+
(error) => eprintln(formatError(error)),
345+
)
346+
let initialWatchers = await createWatchers(
347+
await watchDirectories(scanner),
348+
async function handle(files) {
338349
try {
339350
// If the only change happened to the output file, then we don't want to
340351
// trigger a rebuild because that will result in an infinite loop.
@@ -388,15 +399,19 @@ export async function handle(args: Result<ReturnType<typeof options>>) {
388399

389400
// Setup new watchers
390401
DEBUG && I.start('Setup new watchers')
391-
let newCleanupFunction = await createWatchers(await watchDirectories(scanner), handle)
402+
let newWatchers = await createWatchers(
403+
await watchDirectories(scanner),
404+
handle,
405+
eventBatches!,
406+
)
392407
DEBUG && I.end('Setup new watchers')
393408

394409
// Clear old watchers
395410
DEBUG && I.start('Cleanup old watchers')
396411
await Promise.all(cleanupWatchers.splice(0).map((cleanup) => cleanup()))
397412
DEBUG && I.end('Cleanup old watchers')
398413

399-
cleanupWatchers.push(newCleanupFunction)
414+
cleanupWatchers.push(newWatchers.cleanup)
400415

401416
// Re-compile the CSS
402417
DEBUG && I.start('Build CSS')
@@ -481,17 +496,22 @@ export async function handle(args: Result<ReturnType<typeof options>>) {
481496
let end = process.hrtime.bigint()
482497
if (!args['--silent']) eprintln(`Done in ${formatDuration(end - start)}`)
483498
}
484-
}),
499+
},
500+
eventBatches,
485501
)
502+
eventHandler = initialWatchers.callback
503+
cleanupWatchers.push(initialWatchers.cleanup)
486504

487505
// Abort the watcher if `stdin` is closed to avoid zombie processes. You can
488506
// disable this behavior with `--watch=always`.
489507
if (args['--watch'] !== 'always') {
490508
process.stdin.on('end', () => {
491-
Promise.all(cleanupWatchers.map((fn) => fn())).then(
492-
() => process.exit(0),
493-
() => process.exit(1),
494-
)
509+
Promise.all(cleanupWatchers.map((fn) => fn()))
510+
.then(() => eventBatches?.close())
511+
.then(
512+
() => process.exit(0),
513+
() => process.exit(1),
514+
)
495515
})
496516
}
497517

@@ -515,6 +535,7 @@ export async function handle(args: Result<ReturnType<typeof options>>) {
515535
}
516536

517537
await write(output, map, args, I)
538+
finishInitialBuild()
518539

519540
let end = process.hrtime.bigint()
520541
if (!args['--silent']) eprintln(`Done in ${formatDuration(end - start)}`)
@@ -693,9 +714,13 @@ async function loadWatcher(): Promise<typeof import('@parcel/watcher')> {
693714
}
694715
}
695716

696-
async function createWatchers(dirs: string[], cb: (files: string[]) => void) {
697-
let watcher = await loadWatcher()
698-
717+
export async function createWatchers(
718+
dirs: string[],
719+
cb: (files: string[]) => Promise<void>,
720+
batches: SerialBatches<string>,
721+
watcher?: Awaited<ReturnType<typeof loadWatcher>>,
722+
) {
723+
watcher ??= await loadWatcher()
699724
// Remove any directories that are children of an already watched directory.
700725
// If we don't we may not get notified of certain filesystem events regardless
701726
// of whether or not they are for the directory that is duplicated.
@@ -740,8 +765,9 @@ async function createWatchers(dirs: string[], cb: (files: string[]) => void) {
740765

741766
// Setup a new macrotask to handle the files in batch.
742767
debounceQueue.queueMacrotask(() => {
743-
cb(Array.from(files))
768+
let batch = Array.from(files)
744769
files.clear()
770+
void batches.push(batch)
745771
})
746772
}
747773

@@ -791,9 +817,17 @@ async function createWatchers(dirs: string[], cb: (files: string[]) => void) {
791817
}
792818

793819
// Cleanup
794-
return async () => {
795-
await watchers.dispose()
796-
await debounceQueue.dispose()
820+
return {
821+
callback: cb,
822+
cleanup: async () => {
823+
await watchers.dispose()
824+
await debounceQueue.dispose()
825+
if (files.size > 0) {
826+
let batch = Array.from(files)
827+
files.clear()
828+
void batches.push(batch)
829+
}
830+
},
797831
}
798832
}
799833

0 commit comments

Comments
 (0)