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
+ // `