Skip to content
Closed
Show file tree
Hide file tree
Changes from all 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
69 changes: 69 additions & 0 deletions integrations/cli/config.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -248,6 +248,75 @@ test(
},
)

test(
'Config dependency deletion triggers a rebuild error in watch mode',
{
fs: {
'package.json': json`
{
"dependencies": {
"tailwindcss": "workspace:^",
"@tailwindcss/cli": "workspace:^"
}
}
`,
'index.html': html`
<div class="text-primary"></div>
`,
'tailwind.config.js': js`
const myColor = require('./my-color')
module.exports = {
theme: {
extend: {
colors: {
primary: myColor,
},
},
},
}
`,
'my-color.js': js`module.exports = 'blue'`,
'src/index.css': css`
@import 'tailwindcss';
@config '../tailwind.config.js';
`,
},
},
async ({ fs, exec, spawn }) => {
let process = await spawn(
'pnpm tailwindcss --input src/index.css --output dist/out.css --watch',
)
await process.onStderr((m) => m.includes('Done in'))

await fs.expectFileToContain('dist/out.css', [
//
candidate`text-primary`,
'color: blue',
])

process.flush()
await exec(`node -e "require('node:fs').unlinkSync('my-color.js')"`)
await process.onStderr((m) => m.includes('ENOENT') && m.includes('my-color.js'))

await fs.write('my-color.js', js`module.exports = 'red'`)
// Touch the input CSS to force a new full rebuild after the failed reload.
// At this point the missing dependency is no longer tracked yet.
await fs.write(
'src/index.css',
css`
@import 'tailwindcss';
@config '../tailwind.config.js';
`,
)

await fs.expectFileToContain('dist/out.css', [
//
candidate`text-primary`,
'color: red',
])
Comment thread
greptile-apps[bot] marked this conversation as resolved.
},
)

test(
'Config files (ESM, watch mode)',
{
Expand Down
44 changes: 28 additions & 16 deletions packages/@tailwindcss-cli/src/commands/build/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,11 @@ import { drainStdin, outputFile } from './utils'
const css = String.raw
const DEBUG = env.DEBUG

type WatchEvent = {
path: string
type: 'create' | 'update' | 'delete'
}

export function options() {
return {
'--input': {
Expand Down Expand Up @@ -257,11 +262,11 @@ export async function handle(args: Result<ReturnType<typeof options>>) {
if (args['--watch']) {
let cleanupWatchers: (() => Promise<void>)[] = []
cleanupWatchers.push(
await createWatchers(watchDirectories(scanner), async function handle(files) {
await createWatchers(watchDirectories(scanner), async function handle(events) {
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
if (events.length === 1 && events[0].path === args['--output']) return

using I = new Instrumentation()
DEBUG && I.start('[@tailwindcss/cli] (watcher)')
Expand All @@ -274,22 +279,26 @@ export async function handle(args: Result<ReturnType<typeof options>>) {

let resolvedFullRebuildPaths = fullRebuildPaths

for (let file of files) {
for (let event of events) {
// If one of the changed files is related to the input CSS or JS
// config/plugin files, then we need to do a full rebuild because
// the theme might have changed.
if (resolvedFullRebuildPaths.includes(file)) {
if (resolvedFullRebuildPaths.includes(event.path)) {
rebuildStrategy = 'full'

// No need to check the rest of the events, because we already know we
// need to do a full rebuild.
break
}

// We currently keep scanned candidates cached, so deleting a content
// file does not require an incremental rebuild.
if (event.type === 'delete') continue

// Track new and updated files for incremental rebuilds.
changedFiles.push({
file,
extension: path.extname(file).slice(1),
file: event.path,
extension: path.extname(event.path).slice(1),
} satisfies ChangedContent)
}

Expand Down Expand Up @@ -417,7 +426,7 @@ export async function handle(args: Result<ReturnType<typeof options>>) {
if (!args['--silent']) eprintln(`Done in ${formatDuration(end - start)}`)
}

async function createWatchers(dirs: string[], cb: (files: string[]) => void) {
async function createWatchers(dirs: string[], cb: (events: WatchEvent[]) => void) {
// 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 @@ -447,8 +456,8 @@ async function createWatchers(dirs: string[], cb: (files: string[]) => void) {
// we want to cleanup the old ones we captured here.
let watchers = new Disposables()

// Track all files that were added or changed.
let files = new Set<string>()
// Track the latest relevant event for each path.
let trackedEvents = new Map<string, WatchEvent['type']>()

// Keep track of the debounce queue to avoid multiple rebuilds.
let debounceQueue = new Disposables()
Expand All @@ -462,8 +471,8 @@ async function createWatchers(dirs: string[], cb: (files: string[]) => void) {

// Setup a new macrotask to handle the files in batch.
debounceQueue.queueMacrotask(() => {
cb(Array.from(files))
files.clear()
cb(Array.from(trackedEvents, ([path, type]) => ({ path, type })))
trackedEvents.clear()
})
}

Expand All @@ -479,10 +488,13 @@ async function createWatchers(dirs: string[], cb: (files: string[]) => void) {

await Promise.all(
events.map(async (event) => {
// We currently don't handle deleted files because it doesn't influence
// the CSS output. This is because we currently keep all scanned
// candidates in a cache for performance reasons.
if (event.type === 'delete') return
// We currently keep scanned candidates cached, so deleting a content
// file does not influence the CSS output. However, tracked full
// rebuild dependencies still need to be forwarded to the caller.
if (event.type === 'delete') {
trackedEvents.set(event.path, event.type)
return
}
Comment thread
greptile-apps[bot] marked this conversation as resolved.

// Ignore directory changes. We only care about file changes
let stats: Stats | null = null
Expand All @@ -494,7 +506,7 @@ async function createWatchers(dirs: string[], cb: (files: string[]) => void) {
}

// Track the changed file.
files.add(event.path)
trackedEvents.set(event.path, event.type)
}),
)

Expand Down