Skip to content

Commit c8f0653

Browse files
committed
Fix overlapping CLI watch rebuilds
1 parent c2b24dd commit c8f0653

3 files changed

Lines changed: 217 additions & 14 deletions

File tree

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

Lines changed: 43 additions & 14 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,7 +714,11 @@ async function loadWatcher(): Promise<typeof import('@parcel/watcher')> {
693714
}
694715
}
695716

696-
async function createWatchers(dirs: string[], cb: (files: string[]) => void) {
717+
async function createWatchers(
718+
dirs: string[],
719+
cb: (files: string[]) => Promise<void>,
720+
batches: SerialBatches<string>,
721+
) {
697722
let watcher = await loadWatcher()
698723

699724
// Remove any directories that are children of an already watched directory.
@@ -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,12 @@ 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+
},
797826
}
798827
}
799828

Lines changed: 128 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,128 @@
1+
import { expect, it } from 'vitest'
2+
import { serializeBatches } from './serial-batches'
3+
4+
it('serializes callbacks and coalesces batches received while one is running', async () => {
5+
let releaseFirst!: () => void
6+
let firstCanFinish = new Promise<void>((resolve) => (releaseFirst = resolve))
7+
let batches: string[][] = []
8+
let active = 0
9+
let maxActive = 0
10+
11+
let batchesQueue = serializeBatches<string>(async (batch) => {
12+
batches.push(batch)
13+
active++
14+
maxActive = Math.max(maxActive, active)
15+
16+
if (batches.length === 1) {
17+
await firstCanFinish
18+
}
19+
20+
active--
21+
})
22+
23+
let first = batchesQueue.push(['a'])
24+
await Promise.resolve()
25+
26+
let second = batchesQueue.push(['b'])
27+
let third = batchesQueue.push(['c'])
28+
29+
expect(batches).toEqual([['a']])
30+
expect(maxActive).toBe(1)
31+
32+
releaseFirst()
33+
await Promise.all([first, second, third])
34+
35+
expect(batches).toEqual([['a'], ['b', 'c']])
36+
expect(maxActive).toBe(1)
37+
})
38+
39+
it('shares serialization across watcher generations', async () => {
40+
let releaseFirst!: () => void
41+
let firstCanFinish = new Promise<void>((resolve) => (releaseFirst = resolve))
42+
let calls: string[][] = []
43+
let active = 0
44+
let maxActive = 0
45+
let batchesQueue = serializeBatches<string>(async (batch) => {
46+
calls.push(batch)
47+
active++
48+
maxActive = Math.max(maxActive, active)
49+
if (calls.length === 1) await firstCanFinish
50+
active--
51+
})
52+
53+
// Both watcher generations publish into the command-lifetime queue.
54+
let oldGeneration = (files: string[]) => batchesQueue.push(files)
55+
let newGeneration = (files: string[]) => batchesQueue.push(files)
56+
let first = oldGeneration(['old'])
57+
await Promise.resolve()
58+
let second = newGeneration(['new'])
59+
60+
expect(calls).toEqual([['old']])
61+
releaseFirst()
62+
await Promise.all([first, second])
63+
64+
expect(calls).toEqual([['old'], ['new']])
65+
expect(maxActive).toBe(1)
66+
})
67+
68+
it('drains accepted batches and ignores new work after close', async () => {
69+
let release!: () => void
70+
let canFinish = new Promise<void>((resolve) => (release = resolve))
71+
let calls: string[][] = []
72+
let batchesQueue = serializeBatches<string>(async (batch) => {
73+
calls.push(batch)
74+
await canFinish
75+
})
76+
77+
void batchesQueue.push(['accepted'])
78+
await Promise.resolve()
79+
let closing = batchesQueue.close()
80+
await batchesQueue.push(['late'])
81+
release()
82+
await closing
83+
84+
expect(calls).toEqual([['accepted']])
85+
})
86+
87+
it('holds early watcher events until the initial build is complete', async () => {
88+
let finishInitialBuild!: () => void
89+
let initialBuild = new Promise<void>((resolve) => (finishInitialBuild = resolve))
90+
let calls: string[][] = []
91+
let batchesQueue = serializeBatches<string>(async (batch) => calls.push(batch), initialBuild)
92+
93+
let earlyEvent = batchesQueue.push(['changed-during-initial-build'])
94+
await Promise.resolve()
95+
expect(calls).toEqual([])
96+
97+
finishInitialBuild()
98+
await earlyEvent
99+
expect(calls).toEqual([['changed-during-initial-build']])
100+
})
101+
102+
it('reports callback failures and continues draining accepted batches', async () => {
103+
let releaseFirst!: () => void
104+
let firstCanFail = new Promise<void>((resolve) => (releaseFirst = resolve))
105+
let calls: string[][] = []
106+
let errors: unknown[] = []
107+
let batchesQueue = serializeBatches<string>(
108+
async (batch) => {
109+
calls.push(batch)
110+
if (calls.length === 1) {
111+
await firstCanFail
112+
throw new Error('rebuild failed')
113+
}
114+
},
115+
Promise.resolve(),
116+
(error) => errors.push(error),
117+
)
118+
119+
let first = batchesQueue.push(['first'])
120+
await Promise.resolve()
121+
let second = batchesQueue.push(['accepted-during-first'])
122+
releaseFirst()
123+
await Promise.all([first, second])
124+
125+
expect(calls).toEqual([['first'], ['accepted-during-first']])
126+
expect(errors).toHaveLength(1)
127+
expect(errors[0]).toEqual(new Error('rebuild failed'))
128+
})
Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,46 @@
1+
export interface SerialBatches<T> {
2+
push(batch: T[]): Promise<void>
3+
close(): Promise<void>
4+
}
5+
6+
export function serializeBatches<T>(
7+
callback: (batch: T[]) => Promise<void>,
8+
startAfter: Promise<void> = Promise.resolve(),
9+
onError: (error: unknown) => void = console.error,
10+
): SerialBatches<T> {
11+
let pending: T[] = []
12+
let inFlight: Promise<void> | null = null
13+
let closed = false
14+
15+
function push(batch: T[]): Promise<void> {
16+
if (closed) return Promise.resolve()
17+
pending.push(...batch)
18+
19+
if (inFlight === null) {
20+
inFlight = (async () => {
21+
await startAfter
22+
while (pending.length > 0) {
23+
let next = pending
24+
pending = []
25+
try {
26+
await callback(next)
27+
} catch (error) {
28+
onError(error)
29+
}
30+
}
31+
})().finally(() => {
32+
inFlight = null
33+
})
34+
}
35+
36+
return inFlight
37+
}
38+
39+
return {
40+
push,
41+
async close() {
42+
closed = true
43+
await inFlight
44+
},
45+
}
46+
}

0 commit comments

Comments
 (0)