Skip to content

Commit 00ba565

Browse files
committed
Fix overlapping CLI watch rebuilds
1 parent f723e83 commit 00ba565

5 files changed

Lines changed: 584 additions & 50 deletions

File tree

integrations/cli/index.test.ts

Lines changed: 81 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -409,6 +409,87 @@ 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+
while (!(await fs.stat(new URL('./release-a', import.meta.url)).catch(() => null))) {
434+
await new Promise((resolve) => setTimeout(resolve, 5))
435+
}
436+
await fs.appendFile(new URL('./build-order', import.meta.url), 'A')
437+
438+
export default function ({ addUtilities }) {
439+
addUtilities({ '.from-a': { color: 'red' } })
440+
}
441+
`,
442+
'project-a/plugin-b.mjs': js`
443+
import fs from 'node:fs/promises'
444+
445+
await fs.appendFile(new URL('./build-order', import.meta.url), 'B')
446+
447+
export default function ({ addUtilities }) {
448+
addUtilities({ '.from-b': { color: 'green' } })
449+
}
450+
`,
451+
},
452+
},
453+
async ({ root, fs, spawn, expect }) => {
454+
let process = await spawn(`${command} --input src/index.css --output dist/out.css --watch`, {
455+
cwd: path.join(root, 'project-a'),
456+
})
457+
await process.onStderr((message) => message.includes('Done in'))
458+
459+
await fs.write(
460+
'project-a/src/index.css',
461+
css`
462+
@import 'tailwindcss/utilities';
463+
@plugin '../plugin-a.mjs';
464+
`,
465+
)
466+
await retryAssertion(async () => {
467+
expect(await fs.read('project-a/a-started')).toBe('started')
468+
})
469+
470+
await fs.write(
471+
'project-a/src/index.css',
472+
css`
473+
@import 'tailwindcss/utilities';
474+
@plugin '../plugin-b.mjs';
475+
`,
476+
)
477+
478+
// Allow the native watcher to collect the newer edit while A is paused,
479+
// then release A deterministically.
480+
await new Promise((resolve) => setTimeout(resolve, 100))
481+
await fs.write('project-a/release-a', 'release')
482+
483+
await process.onStderr((message) => message.includes('Done in'))
484+
await process.onStderr((message) => message.includes('Done in'))
485+
486+
expect(await fs.read('project-a/build-order')).toBe('AB')
487+
await fs.expectFileToContain('project-a/dist/out.css', [candidate`from-b`])
488+
await fs.expectFileNotToContain('project-a/dist/out.css', [candidate`from-a`])
489+
},
490+
{ skip: kind !== 'CLI' },
491+
)
492+
412493
test(
413494
"watch mode with unknown @source paths shouldn't crash on Windows",
414495
{
Lines changed: 126 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,126 @@
1+
import { expect, it } from 'vitest'
2+
import { serializeBatches } from '../../utils/serial-batches'
3+
import { createWatchers, filterChangedFiles } from './index'
4+
5+
type WatchEvent = { type: 'create' | 'update' | 'delete'; path: string }
6+
type WatchCallback = (error: Error | null, events: WatchEvent[]) => Promise<void>
7+
8+
function fakeWatcher() {
9+
let callbacks: WatchCallback[] = []
10+
return {
11+
callbacks,
12+
watcher: {
13+
async subscribe(_directory: string, callback: WatchCallback) {
14+
callbacks.push(callback)
15+
return { unsubscribe() {} }
16+
},
17+
},
18+
}
19+
}
20+
21+
function nextTask() {
22+
return new Promise((resolve) => setTimeout(resolve, 0))
23+
}
24+
25+
it('removes duplicate output and map events from a coalesced batch', () => {
26+
expect(
27+
filterChangedFiles(
28+
['output.css', 'source.html', 'output.css', 'output.css.map'],
29+
'output.css',
30+
'output.css.map',
31+
),
32+
).toEqual(['source.html'])
33+
})
34+
35+
it('uses one rebuild queue across watcher generations', async () => {
36+
let releaseFirst!: () => void
37+
let firstCanFinish = new Promise<void>((resolve) => (releaseFirst = resolve))
38+
let calls: string[][] = []
39+
let queue = serializeBatches<string>(async (files) => {
40+
calls.push(files)
41+
if (calls.length === 1) await firstCanFinish
42+
})
43+
let first = fakeWatcher()
44+
let second = fakeWatcher()
45+
46+
let oldGeneration = await createWatchers(['/old'], async () => {}, queue, first.watcher)
47+
await first.callbacks[0](null, [{ type: 'delete', path: 'old-change' }])
48+
await nextTask()
49+
expect(calls).toEqual([['old-change']])
50+
51+
let newGeneration = await createWatchers(['/new'], async () => {}, queue, second.watcher)
52+
await second.callbacks[0](null, [{ type: 'delete', path: 'new-change' }])
53+
await nextTask()
54+
expect(calls).toEqual([['old-change']])
55+
56+
releaseFirst()
57+
await queue.close()
58+
expect(calls).toEqual([['old-change'], ['new-change']])
59+
60+
await Promise.all([oldGeneration.cleanup(), newGeneration.cleanup()])
61+
})
62+
63+
it('buffers watcher events until the initial build finishes', async () => {
64+
let finishInitialBuild!: () => void
65+
let initialBuild = new Promise<void>((resolve) => (finishInitialBuild = resolve))
66+
let calls: string[][] = []
67+
let queue = serializeBatches<string>(async (files) => {
68+
calls.push(files)
69+
}, initialBuild)
70+
let fake = fakeWatcher()
71+
let generation = await createWatchers(['/watch'], async () => {}, queue, fake.watcher)
72+
73+
await fake.callbacks[0](null, [{ type: 'delete', path: 'early-change' }])
74+
await nextTask()
75+
expect(calls).toEqual([])
76+
77+
finishInitialBuild()
78+
await queue.close()
79+
expect(calls).toEqual([['early-change']])
80+
await generation.cleanup()
81+
})
82+
83+
it('flushes a collected event when shutdown cancels its debounce timer', async () => {
84+
let calls: string[][] = []
85+
let queue = serializeBatches<string>(async (files) => {
86+
calls.push(files)
87+
})
88+
let fake = fakeWatcher()
89+
let generation = await createWatchers(['/watch'], async () => {}, queue, fake.watcher)
90+
91+
await fake.callbacks[0](null, [{ type: 'delete', path: 'last-change' }])
92+
await generation.cleanup()
93+
await queue.close()
94+
95+
expect(calls).toEqual([['last-change']])
96+
})
97+
98+
it('waits for an entered watcher callback before shutdown flushes changes', async () => {
99+
let releaseLstat!: () => void
100+
let lstatCanFinish = new Promise<void>((resolve) => (releaseLstat = resolve))
101+
let calls: string[][] = []
102+
let queue = serializeBatches<string>(async (files) => {
103+
calls.push(files)
104+
})
105+
let fake = fakeWatcher()
106+
let generation = await createWatchers(
107+
['/watch'],
108+
async () => {},
109+
queue,
110+
fake.watcher,
111+
async () => {
112+
await lstatCanFinish
113+
return { isFile: () => true, isSymbolicLink: () => false }
114+
},
115+
)
116+
117+
let callback = fake.callbacks[0](null, [{ type: 'update', path: 'delayed-change' }])
118+
let cleanup = generation.cleanup()
119+
await nextTask()
120+
expect(calls).toEqual([])
121+
122+
releaseLstat()
123+
await Promise.all([callback, cleanup])
124+
await queue.close()
125+
expect(calls).toEqual([['delayed-change']])
126+
})

0 commit comments

Comments
 (0)