diff --git a/integrations/vite/build-watch.test.ts b/integrations/vite/build-watch.test.ts new file mode 100644 index 000000000000..10008f4b8853 --- /dev/null +++ b/integrations/vite/build-watch.test.ts @@ -0,0 +1,92 @@ +import path from 'node:path' +import { candidate, css, html, js, json, test } from '../utils' + +test( + 'build watch ignores a custom output directory', + { + fs: { + 'package.json': json` + { + "type": "module", + "dependencies": { + "@tailwindcss/vite": "workspace:^", + "tailwindcss": "workspace:^" + }, + "devDependencies": { + "vite": "6.2.3" + } + } + `, + 'vite.config.js': js` + import tailwindcss from '@tailwindcss/vite' + import { defineConfig } from 'vite' + + export default defineConfig({ + plugins: [tailwindcss()], + build: { + outDir: './assets', + emptyOutDir: false, + minify: false, + rollupOptions: { + input: { + app: './resources/js/app.js', + styles: './resources/css/app.css', + }, + output: { + dir: './assets', + entryFileNames: '[name].js', + chunkFileNames: '[name].js', + assetFileNames: '[name].css', + }, + }, + }, + }) + `, + 'resources/js/app.js': js` + import '../css/app.css' + + document.body.innerHTML = '
Hello
' + `, + 'resources/css/app.css': css` + @import 'tailwindcss'; + @source '../../assets/source.html'; + `, + 'assets/app.js': js` console.log('previous build') `, + 'assets/source.html': html`
`, + 'assets/styles.css': css` + .previous-build { + display: block; + } + `, + }, + }, + async ({ root, spawn, exec, fs, expect }) => { + let version = await exec('pnpm vite --version') + expect(version).toContain('vite/6.2.3') + + let process = await spawn('pnpm vite build --watch') + await process.onStdout((message) => message.includes('built in')) + + let output = path.join(root, 'assets/styles.css') + await fs.expectFileToContain(output, [candidate`font-black`, candidate`underline`]) + + process.flush() + let result = await Promise.race([ + process.onStdout((message) => message.includes('built in')).then(() => 'rebuilt'), + new Promise<'idle'>((resolve) => setTimeout(() => resolve('idle'), 1_500)), + ]) + expect(result).toBe('idle') + + process.flush() + await fs.write( + 'resources/js/app.js', + js` + import '../css/app.css' + + document.body.innerHTML = '
Hello
' + `, + ) + await process.onStdout((message) => message.includes('built in')) + await fs.expectFileToContain(output, candidate`font-bold`) + }, +) diff --git a/packages/@tailwindcss-vite/src/index.ts b/packages/@tailwindcss-vite/src/index.ts index 8fc660eaac18..e849469784e3 100644 --- a/packages/@tailwindcss-vite/src/index.ts +++ b/packages/@tailwindcss-vite/src/index.ts @@ -166,6 +166,7 @@ export default function tailwindcss(opts: PluginOptions = {}): Plugin[] { return new Root( id, config!.root, + config!.command === 'build' ? path.resolve(config!.root, config!.build.outDir) : null, // Currently, Vite only supports CSS source maps in development and they // are off by default. Check to see if we need them or not. config?.css.devSourcemap ?? false, @@ -338,6 +339,10 @@ class Root { // The lazily-initialized Tailwind scanner. private scanner?: Scanner + // A scanner for explicit `@source` paths that can opt back into paths which + // are excluded from automatic source detection. + private explicitScanner?: Scanner + // List of all candidates that were being returned by the root scanner during // the lifetime of the root. private candidates: Set = new Set() @@ -350,6 +355,7 @@ class Root { constructor( private id: string, private base: string, + private buildOutputDirectory: string | null, private enableSourceMaps: boolean, private customCssResolver: (id: string, base: string) => Promise, @@ -431,7 +437,21 @@ class Root { return [{ ...this.compiler.root, negated: false }] })().concat(this.compiler.sources) + if (this.buildOutputDirectory !== null && this.compiler.root === null) { + // Watching files emitted by Vite would trigger another build every time + // the output is written. + sources.push({ + base: this.buildOutputDirectory, + pattern: '**/*', + negated: true, + }) + } + this.scanner = new Scanner({ sources }) + this.explicitScanner = + this.compiler.sources.length > 0 + ? new Scanner({ sources: this.compiler.sources }) + : undefined DEBUG && I.end('Setup scanner') } else { for (let buildDependency of this.buildDependencies.keys()) { @@ -457,47 +477,55 @@ class Root { // setup a new scanner and compiler every time we request the CSS file // (regardless whether it actually changed or not). DEBUG && I.start('Scan for candidates') - for (let candidate of this.scanner.scan()) { - this.candidates.add(candidate) + for (let scanner of [this.scanner, this.explicitScanner]) { + if (!scanner) continue + + for (let candidate of scanner.scan()) { + this.candidates.add(candidate) + } } DEBUG && I.end('Scan for candidates') } if (this.compiler.features & Features.Utilities) { DEBUG && I.start('Register dependency messages') - // Watch individual files found via custom `@source` paths - for (let file of this.scanner.files) { - addWatchFile(file) - } - - // Watch globs found via custom `@source` paths - for (let glob of this.scanner.globs) { - if (glob.pattern[0] === '!') continue + for (let scanner of [this.scanner, this.explicitScanner]) { + if (!scanner) continue - let relative = path.relative(this.base, glob.base) - if (relative[0] !== '.') { - relative = './' + relative + // Watch individual files found via custom `@source` paths + for (let file of scanner.files) { + addWatchFile(file) } - // Ensure relative is a posix style path since we will merge it with the - // glob. - relative = normalizePath(relative) - addWatchFile(path.posix.join(relative, glob.pattern)) + // Watch globs found via custom `@source` paths + for (let glob of scanner.globs) { + if (glob.pattern[0] === '!') continue - let root = this.compiler.root + let relative = path.relative(this.base, glob.base) + if (relative[0] !== '.') { + relative = './' + relative + } + // Ensure relative is a posix style path since we will merge it with the + // glob. + relative = normalizePath(relative) - if (root !== 'none' && root !== null) { - let basePath = normalizePath(path.resolve(root.base, root.pattern)) + addWatchFile(path.posix.join(relative, glob.pattern)) - let isDir = await fs.stat(basePath).then( - (stats) => stats.isDirectory(), - () => false, - ) + let root = this.compiler.root - if (!isDir) { - throw new Error( - `The path given to \`source(…)\` must be a directory but got \`source(${basePath})\` instead.`, + if (root !== 'none' && root !== null) { + let basePath = normalizePath(path.resolve(root.base, root.pattern)) + + let isDir = await fs.stat(basePath).then( + (stats) => stats.isDirectory(), + () => false, ) + + if (!isDir) { + throw new Error( + `The path given to \`source(…)\` must be a directory but got \`source(${basePath})\` instead.`, + ) + } } } }