Repository: tailwindlabs/tailwindcss

Problem statement:
[v4] Vite plugin breaks svelte global animation keyframes handling
**What version of Tailwind CSS are you using?**

v4.0.0-alpha.32

**What build tool (or framework if it abstracts the build tool) are you using?**

Vite with SvelteKit 2.8.0 (latest)

**What version of Node.js are you using?**

For example: v23.1.0

**What browser are you using?**

Chrome

**What operating system are you using?**

MacOS

**Reproduction URL**

https://github.com/emmbm/tailwind-svelte-keyframes

**Describe your issue**

Updating from `@tailwindcss/vite@4.0.0-alpha.31` to `@tailwindcss/vite@4.0.0-alpha.32` breaks svelte's expected handling (read here _renaming_) of global keyframes defined in style blocks.

In svelte components, styles are scoped by default. This scoping also applies to keyframes, [unless they are defined with a `-global-` prefix which svelte then removes during build](https://svelte.dev/docs/svelte/global-styles#:global()). This behavior is broken in the current `next` version of tailwind.


Hints:
Yeah it does seem that the default Svelte transforms now no longer apply at all. Looking into it, thanks for the report.

Patch:
diff --git a/integrations/utils.ts b/integrations/utils.ts
--- a/integrations/utils.ts
+++ b/integrations/utils.ts
@@ -46,7 +46,7 @@ interface TestContext {
     dumpFiles(pattern: string): Promise<string>
     expectFileToContain(
       filePath: string,
-      contents: string | string[] | RegExp | RegExp[],
+      contents: string | RegExp | (string | RegExp)[],
     ): Promise<void>
     expectFileNotToContain(filePath: string, contents: string | string[]): Promise<void>
   }
diff --git a/packages/@tailwindcss-vite/src/index.ts b/packages/@tailwindcss-vite/src/index.ts
--- a/packages/@tailwindcss-vite/src/index.ts
+++ b/packages/@tailwindcss-vite/src/index.ts
@@ -59,7 +59,7 @@ export default function tailwindcss(): Plugin[] {
         if (!module) {
           // The module for this root might not exist yet
           if (root.builtBeforeTransform) {
-            return
+            continue
           }
 
           // Note: Removing this during SSR is not safe and will produce
@@ -196,17 +196,17 @@ export default function tailwindcss(): Plugin[] {
 
         let root = roots.get(id)
 
+        // If the root was built outside of the transform hook (e.g. in the
+        // Svelte preprocessor), we still want to mark all dependencies of the
+        // root as watched files.
         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
+        }
+
+        // We only process Svelte `<style>` tags in the `sveltePreprocessor`
+        if (isSvelteStyle(id)) {
+          return src
         }
 
         if (!options?.ssr) {
@@ -240,16 +240,17 @@ export default function tailwindcss(): Plugin[] {
 
         let root = roots.get(id)
 
+        // If the root was built outside of the transform hook (e.g. in the
+        // Svelte preprocessor), we still want to mark all dependencies of the
+        // root as watched files.
         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 only process Svelte `<style>` tags in the `sveltePreprocessor`
+        if (isSvelteStyle(id)) {
+          return src
         }
 
         // We do a first pass to generate valid CSS for the downstream plugins.
@@ -268,6 +269,9 @@ export default function tailwindcss(): Plugin[] {
       // by vite:css-post.
       async renderStart() {
         for (let [id, root] of roots.entries()) {
+          // Do not do a second render pass on Svelte `<style>` tags.
+          if (isSvelteStyle(id)) continue
+
           let generated = await regenerateOptimizedCss(
             root,
             // During the renderStart phase, we can not add watch files since
@@ -304,13 +308,20 @@ function isPotentialCssRootFile(id: string) {
     (extension === 'css' ||
       (extension === 'vue' && id.includes('&lang.css')) ||
       (extension === 'astro' && id.includes('&lang.css')) ||
-      (extension === 'svelte' && id.includes('&lang.css'))) &&
+      // We want to process Svelte `<style>` tags to properly add dependency
+      // tracking for imported files.
+      isSvelteStyle(id)) &&
     // Don't intercept special static asset resources
     !SPECIAL_QUERY_RE.test(id)
 
   return isCssFile
 }
 
+function isSvelteStyle(id: string) {
+  let extension = getExtension(id)
+  return extension === 'svelte' && id.includes('&lang.css')
+}
+
 function optimizeCss(
   input: string,
   { file = 'input.css', minify = false }: { file?: string; minify?: boolean } = {},
@@ -403,6 +414,8 @@ class Root {
   // The resolved path given to `source(…)`. When not given this is `null`.
   private basePath: string | null = null
 
+  public overwriteCandidates: string[] | null = null
+
   constructor(
     private id: string,
     private getSharedCandidates: () => Map<string, Set<string>>,
@@ -453,14 +466,16 @@ class Root {
       this.scanner = new Scanner({ sources })
     }
 
-    // This should not be here, but right now the Vite plugin is setup where we
-    // setup a new scanner and compiler every time we request the CSS file
-    // (regardless whether it actually changed or not).
-    env.DEBUG && console.time('[@tailwindcss/vite] Scan for candidates')
-    for (let candidate of this.scanner.scan()) {
-      this.candidates.add(candidate)
+    if (!this.overwriteCandidates) {
+      // This should not be here, but right now the Vite plugin is setup where we
+      // setup a new scanner and compiler every time we request the CSS file
+      // (regardless whether it actually changed or not).
+      env.DEBUG && console.time('[@tailwindcss/vite] Scan for candidates')
+      for (let candidate of this.scanner.scan()) {
+        this.candidates.add(candidate)
+      }
+      env.DEBUG && console.timeEnd('[@tailwindcss/vite] Scan for candidates')
     }
-    env.DEBUG && console.timeEnd('[@tailwindcss/vite] Scan for candidates')
 
     // Watch individual files found via custom `@source` paths
     for (let file of this.scanner.files) {
@@ -506,7 +521,11 @@ class Root {
     this.requiresRebuild = true
 
     env.DEBUG && console.time('[@tailwindcss/vite] Build CSS')
-    let result = this.compiler.build([...this.sharedCandidates(), ...this.candidates])
+    let result = this.compiler.build(
+      this.overwriteCandidates
+        ? this.overwriteCandidates
+        : [...this.sharedCandidates(), ...this.candidates],
+    )
     env.DEBUG && console.timeEnd('[@tailwindcss/vite] Build CSS')
 
     return result
@@ -552,50 +571,72 @@ class Root {
 // enabled. This allows us to transform CSS in `<style>` tags and create a
 // stricter version of CSS that passes the Svelte compiler.
 //
-// Note that these files will undergo a second pass through the vite transpiler
-// later. This is necessary to compute `@tailwind utilities;` with the right
-// candidate list.
+// Note that these files will not undergo a second pass through the vite
+// transpiler later. This means that `@tailwind utilities;` will not be up to
+// date.
 //
-// In practice, it is not recommended to use `@tailwind utilities;` inside
-// Svelte components. Use an external `.css` file instead.
+// In practice, it is discouraged to use `@tailwind utilities;` inside Svelte
+// components, as the styles it create would be scoped anyways. Use an external
+// `.css` file instead.
 function svelteProcessor(roots: DefaultMap<string, Root>) {
+  let preprocessor = sveltePreprocess()
+
   return {
     name: '@tailwindcss/svelte',
     api: {
-      sveltePreprocess: sveltePreprocess({
-        aliases: [
-          ['postcss', 'tailwindcss'],
-          ['css', 'tailwindcss'],
-        ],
-        async tailwindcss({
+      sveltePreprocess: {
+        markup: preprocessor.markup,
+        script: preprocessor.script,
+        async style({
           content,
-          attributes,
           filename,
+          markup,
+          ...rest
         }: {
           content: string
-          attributes: Record<string, string>
           filename?: string
+          attributes: Record<string, string | boolean>
+          markup: string
         }) {
-          if (!filename) return
+          if (!filename) return preprocessor.style?.({ ...rest, content, filename, markup })
+
+          // Create the ID used by Vite to identify the `<style>` contents. This
+          // way, the Vite `transform` hook can find the right root and thus
+          // track the right dependencies.
           let id = filename + '?svelte&type=style&lang.css'
 
           let root = roots.get(id)
+
+          // Since a Svelte pre-processor call means that the CSS has changed,
+          // we need to trigger a rebuild.
+          root.requiresRebuild = true
+
           // Mark this root as being built before the Vite transform hook is
           // called. We capture all eventually added dependencies so that we can
           // connect them to the vite module graph later, when the transform
           // hook is called.
           root.builtBeforeTransform = []
+
+          // We only want to consider candidates from the current template file,
+          // this ensures that no one can depend on this having the full candidate
+          // list in some builds (as this is undefined behavior).
+          let scanner = new Scanner({})
+          root.overwriteCandidates = scanner.scanFiles([
+            { content: markup, file: filename, extension: 'svelte' },
+          ])
+
           let generated = await root.generate(content, (file) =>
             root?.builtBeforeTransform?.push(file),
           )
 
           if (!generated) {
             roots.delete(id)
-            return { code: content, attributes }
+            return preprocessor.style?.({ ...rest, content, filename, markup })
           }
-          return { code: generated, attributes }
+
+          return preprocessor.style?.({ ...rest, content: generated, filename, markup })
         },
-      }),
+      },
     },
   }
 }


Test patch:
diff --git a/integrations/vite/svelte.test.ts b/integrations/vite/svelte.test.ts
--- a/integrations/vite/svelte.test.ts
+++ b/integrations/vite/svelte.test.ts
@@ -48,22 +48,50 @@ test(
           target: document.body,
         })
       `,
+      'src/index.css': css`
+        @import 'tailwindcss/theme' theme(reference);
+        @import 'tailwindcss/utilities';
+      `,
       'src/App.svelte': html`
         <script>
+          import './index.css'
           let name = 'world'
         </script>
 
-        <h1 class="foo underline">Hello {name}!</h1>
+        <h1 class="global local underline">Hello {name}!</h1>
 
-        <style global>
-          @import 'tailwindcss/utilities';
+        <style>
           @import 'tailwindcss/theme' theme(reference);
-          @import './components.css';
+          @import './other.css';
         </style>
       `,
-      'src/components.css': css`
-        .foo {
+      'src/other.css': css`
+        .local {
           @apply text-red-500;
+          animation: 2s ease-in-out 0s infinite localKeyframes;
+        }
+
+        :global(.global) {
+          @apply text-green-500;
+          animation: 2s ease-in-out 0s infinite globalKeyframes;
+        }
+
+        @keyframes -global-globalKeyframes {
+          0% {
+            opacity: 0;
+          }
+          100% {
+            opacity: 100%;
+          }
+        }
+
+        @keyframes localKeyframes {
+          0% {
+            opacity: 0;
+          }
+          100% {
+            opacity: 100%;
+          }
         }
       `,
     },
@@ -74,7 +102,13 @@ test(
     let files = await fs.glob('dist/**/*.css')
     expect(files).toHaveLength(1)
 
-    await fs.expectFileToContain(files[0][0], [candidate`underline`, candidate`foo`])
+    await fs.expectFileToContain(files[0][0], [
+      candidate`underline`,
+      '.global{color:var(--color-green-500);animation:2s ease-in-out 0s infinite globalKeyframes}',
+      /\.local.svelte-.*\{color:var\(--color-red-500\);animation:2s ease-in-out 0s infinite svelte-.*-localKeyframes\}/,
+      /@keyframes globalKeyframes\{/,
+      /@keyframes svelte-.*-localKeyframes\{/,
+    ])
   },
 )
 
@@ -127,20 +161,48 @@ test(
       `,
       'src/App.svelte': html`
         <script>
+          import './index.css'
           let name = 'world'
         </script>
 
-        <h1 class="foo underline">Hello {name}!</h1>
+        <h1 class="local global underline">Hello {name}!</h1>
 
-        <style global>
-          @import 'tailwindcss/utilities';
+        <style>
           @import 'tailwindcss/theme' theme(reference);
-          @import './components.css';
+          @import './other.css';
         </style>
       `,
-      'src/components.css': css`
-        .foo {
+      'src/index.css': css`
+        @import 'tailwindcss/theme' theme(reference);
+        @import 'tailwindcss/utilities';
+      `,
+      'src/other.css': css`
+        .local {
           @apply text-red-500;
+          animation: 2s ease-in-out 0s infinite localKeyframes;
+        }
+
+        :global(.global) {
+          @apply text-green-500;
+          animation: 2s ease-in-out 0s infinite globalKeyframes;
+        }
+
+        @keyframes -global-globalKeyframes {
+          0% {
+            opacity: 0;
+          }
+          100% {
+            opacity: 100%;
+          }
+        }
+
+        @keyframes localKeyframes {
+          0% {
+            opacity: 0;
+          }
+          100% {
+            opacity: 100%;
+          }
         }
       `,
     },
@@ -148,30 +210,45 @@ test(
   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]
+      let [, css] = files[0]
+      expect(css).toContain(candidate`underline`)
+      expect(css).toContain(
+        '.global{color:var(--color-green-500);animation:2s ease-in-out 0s infinite globalKeyframes}',
+      )
+      expect(css).toMatch(
+        /\.local.svelte-.*\{color:var\(--color-red-500\);animation:2s ease-in-out 0s infinite svelte-.*-localKeyframes\}/,
+      )
+      expect(css).toMatch(/@keyframes globalKeyframes\{/)
+      expect(css).toMatch(/@keyframes svelte-.*-localKeyframes\{/)
     })
 
-    await fs.expectFileToContain(filename, [candidate`foo`, candidate`underline`])
+    await fs.write(
+      'src/App.svelte',
+      (await fs.read('src/App.svelte')).replace('underline', 'font-bold bar'),
+    )
 
     await fs.write(
-      'src/components.css',
-      css`
-        .bar {
-          @apply text-green-500;
-        }
-      `,
+      'src/other.css',
+      `${await fs.read('src/other.css')}\n.bar { @apply text-pink-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`)
+      expect(css).toContain(candidate`font-bold`)
+      expect(css).toContain(
+        '.global{color:var(--color-green-500);animation:2s ease-in-out 0s infinite globalKeyframes}',
+      )
+      expect(css).toMatch(
+        /\.local.svelte-.*\{color:var\(--color-red-500\);animation:2s ease-in-out 0s infinite svelte-.*-localKeyframes\}/,
+      )
+      expect(css).toMatch(/@keyframes globalKeyframes\{/)
+      expect(css).toMatch(/@keyframes svelte-.*-localKeyframes\{/)
+      expect(css).toMatch(/\.bar.svelte-.*\{color:var\(--color-pink-500\)\}/)
     })
   },
 )