diff --git a/integrations/vite/svelte.test.ts b/integrations/vite/svelte.test.ts new file mode 100644 index 000000000000..990ede3aadb3 --- /dev/null +++ b/integrations/vite/svelte.test.ts @@ -0,0 +1,177 @@ +import { expect } from 'vitest' +import { candidate, css, html, json, retryAssertion, test, ts } from '../utils' + +test( + 'production build', + { + fs: { + 'package.json': json` + { + "type": "module", + "dependencies": { + "svelte": "^4.2.18", + "tailwindcss": "workspace:^" + }, + "devDependencies": { + "@sveltejs/vite-plugin-svelte": "^3.1.1", + "@tailwindcss/vite": "workspace:^", + "vite": "^5.3.5" + } + } + `, + 'vite.config.ts': ts` + import { defineConfig } from 'vite' + import { svelte, vitePreprocess } from '@sveltejs/vite-plugin-svelte' + import tailwindcss from '@tailwindcss/vite' + + export default defineConfig({ + plugins: [ + svelte({ + preprocess: [vitePreprocess()], + }), + tailwindcss(), + ], + }) + `, + 'index.html': html` + + + +
+ + + + `, + 'src/main.ts': ts` + import App from './App.svelte' + const app = new App({ + target: document.body, + }) + `, + 'src/App.svelte': html` + + +

Hello {name}!

+ + + `, + 'src/components.css': css` + .foo { + @apply text-red-500; + } + `, + }, + }, + async ({ fs, exec }) => { + await exec('pnpm vite build') + + let files = await fs.glob('dist/**/*.css') + expect(files).toHaveLength(1) + + await fs.expectFileToContain(files[0][0], [candidate`underline`, candidate`foo`]) + }, +) + +test( + 'watch mode', + { + fs: { + 'package.json': json` + { + "type": "module", + "dependencies": { + "svelte": "^4.2.18", + "tailwindcss": "workspace:^" + }, + "devDependencies": { + "@sveltejs/vite-plugin-svelte": "^3.1.1", + "@tailwindcss/vite": "workspace:^", + "vite": "^5.3.5" + } + } + `, + 'vite.config.ts': ts` + import { defineConfig } from 'vite' + import { svelte, vitePreprocess } from '@sveltejs/vite-plugin-svelte' + import tailwindcss from '@tailwindcss/vite' + + export default defineConfig({ + plugins: [ + svelte({ + preprocess: [vitePreprocess()], + }), + tailwindcss(), + ], + }) + `, + 'index.html': html` + + + +
+ + + + `, + 'src/main.ts': ts` + import App from './App.svelte' + const app = new App({ + target: document.body, + }) + `, + 'src/App.svelte': html` + + +

Hello {name}!

+ + + `, + 'src/components.css': css` + .foo { + @apply text-red-500; + } + `, + }, + }, + async ({ fs, spawn }) => { + await spawn(`pnpm vite build --watch`) + + let filename = '' + await retryAssertion(async () => { + let files = await fs.glob('dist/**/*.css') + expect(files).toHaveLength(1) + filename = files[0][0] + }) + + await fs.expectFileToContain(filename, [candidate`foo`, candidate`underline`]) + + await fs.write( + 'src/components.css', + css` + .bar { + @apply text-green-500; + } + `, + ) + await retryAssertion(async () => { + let files = await fs.glob('dist/**/*.css') + expect(files).toHaveLength(1) + let [, css] = files[0] + expect(css).toContain(candidate`underline`) + expect(css).toContain(candidate`bar`) + expect(css).not.toContain(candidate`foo`) + }) + }, +) diff --git a/packages/@tailwindcss-vite/package.json b/packages/@tailwindcss-vite/package.json index 1608d3686ab0..cfe2ac7dc233 100644 --- a/packages/@tailwindcss-vite/package.json +++ b/packages/@tailwindcss-vite/package.json @@ -33,6 +33,7 @@ "lightningcss": "catalog:", "postcss": "^8.4.41", "postcss-import": "^16.1.0", + "svelte-preprocess": "^6.0.2", "tailwindcss": "workspace:^" }, "devDependencies": { diff --git a/packages/@tailwindcss-vite/src/index.ts b/packages/@tailwindcss-vite/src/index.ts index 8a1c60932e00..f29b362d28cf 100644 --- a/packages/@tailwindcss-vite/src/index.ts +++ b/packages/@tailwindcss-vite/src/index.ts @@ -8,6 +8,7 @@ import fs from 'node:fs/promises' import path from 'path' import postcss from 'postcss' import postcssImport from 'postcss-import' +import { sveltePreprocess } from 'svelte-preprocess' import type { Plugin, ResolvedConfig, Rollup, Update, ViteDevServer } from 'vite' export default function tailwindcss(): Plugin[] { @@ -60,9 +61,14 @@ export default function tailwindcss(): Plugin[] { function invalidateAllRoots(isSSR: boolean) { for (let server of servers) { let updates: Update[] = [] - for (let id of roots.keys()) { + for (let [id, root] of roots.entries()) { let module = server.moduleGraph.getModuleById(id) if (!module) { + // The module for this root might not exist yet + if (root.builtBeforeTransform) { + return + } + // Note: Removing this during SSR is not safe and will produce // inconsistent results based on the timing of the removal and // the order / timing of transforms. @@ -133,6 +139,7 @@ export default function tailwindcss(): Plugin[] { } return [ + svelteProcessor(roots), { // Step 1: Scan source files for candidates name: '@tailwindcss/vite:scan', @@ -184,6 +191,19 @@ export default function tailwindcss(): Plugin[] { let root = roots.get(id) + if (root.builtBeforeTransform) { + root.builtBeforeTransform.forEach((file) => this.addWatchFile(file)) + root.builtBeforeTransform = undefined + // When a root was built before this transform hook, the candidate + // list might be outdated already by the time the transform hook is + // called. + // + // This requires us to build the CSS file again. However, we do not + // expect dependencies to have changed, so we can avoid a full + // rebuild. + root.requiresRebuild = false + } + if (!options?.ssr) { // Wait until all other files have been processed, so we can extract // all candidates before generating CSS. This must not be called @@ -215,6 +235,18 @@ export default function tailwindcss(): Plugin[] { let root = roots.get(id) + if (root.builtBeforeTransform) { + root.builtBeforeTransform.forEach((file) => this.addWatchFile(file)) + root.builtBeforeTransform = undefined + // When a root was built before this transform hook, the candidate + // list might be outdated already by the time the transform hook is + // called. + // + // Since we already do a second render pass in build mode, we don't + // need to do any more work here. + return + } + // We do a first pass to generate valid CSS for the downstream plugins. // However, since not all candidates are guaranteed to be extracted by // this time, we have to re-run a transform for the root later. @@ -261,11 +293,13 @@ function getExtension(id: string) { } function isPotentialCssRootFile(id: string) { + if (id.includes('/.vite/')) return let extension = getExtension(id) let isCssFile = extension === 'css' || (extension === 'vue' && id.includes('&lang.css')) || - (extension === 'astro' && id.includes('&lang.css')) + (extension === 'astro' && id.includes('&lang.css')) || + (extension === 'svelte' && id.includes('&lang.css')) return isCssFile } @@ -336,6 +370,14 @@ class Root { // `renderStart` hook. public lastContent: string = '' + // When set, indicates that the root was built before the Vite transform hook + // was being called. This can happen in scenarios like when preprocessing + // `