Repository: tailwindlabs/tailwindcss

Problem statement:
[v4] vite build --watch only compile html classes right on the init run


**What version of Tailwind CSS are you using?**

v4

**What build tool (or framework if it abstracts the build tool) are you using?**

Plain vite Projekt with css/html Input files in rollup config

**What version of Node.js are you using?**

v20.11.0

**What browser are you using?**

Chrome

**What operating system are you using?**

Linux Debian 

**Describe your issue**

I have some css files ans HTML files. The HTML contains some tailwindcss classes like p-2 etc. If i run vite build --watch the first build is correct. And the p-2 css Definition is in the Output css. But if i change the classes in the HTML it recompiles but dont get the new classes. If i use @apply in my source css file the Compiler works every time. So must be a bug in the HTML stuff.


Hints:
try adding purge: ["./src/**/*.{html,js}"], in the tailwind config file
@Massa-Albani4 Its the vite Plugin from v4 no tailwindcss config anymore
@KartoffelToby Heya! Do you have a repro that you can share? We've been looking into the Vite plugin just last week and as a result of that also added a new integration test that does cover [changing a `.html` file in watch mode](https://github.com/tailwindlabs/tailwindcss/blob/next/integrations/vite/index.test.ts#L78-L157) if you need a starting point. 
**1. Check Vite Configuration:**
Ensure that your Vite configuration is set up correctly to watch for changes in HTML files. Your vite.config.js should include the HTML files in the build.rollupOptions.input configuration.

```javascript
import { defineConfig } from "vite"
import path from "path"

export default defineConfig({
  build: {
    rollupOptions: {
      input: {
        main: path.resolve(__dirname, "index.html"),
        // Add other HTML entry points here
      }
    }
  }
})
``` 
/ 
/
**2. Tailwind CSS Configuration:**
Make sure your tailwind.config.js is correctly configured to purge unused styles from the correct files. For example:

```javascript
module.exports = {
  content: [
    "/index.html",
    "./src/**/*.{js,ts,jsx,tsx,html}",
  ],
  theme: {
    extend: {},
  },
  plugins: [],
}
``` 
/ 
/

**3. Update Dependencies:**
Ensure all dependencies are up-to-date, including Vite and Tailwind CSS. Run:
`npm update`

Patch:
diff --git a/integrations/utils.ts b/integrations/utils.ts
--- a/integrations/utils.ts
+++ b/integrations/utils.ts
@@ -1,7 +1,7 @@
 import dedent from 'dedent'
 import fastGlob from 'fast-glob'
 import killPort from 'kill-port'
-import { execSync, spawn } from 'node:child_process'
+import { exec, spawn } from 'node:child_process'
 import fs from 'node:fs/promises'
 import net from 'node:net'
 import { homedir, platform, tmpdir } from 'node:os'
@@ -89,11 +89,23 @@ export function test(
             console.log(`> cd ${relative}`)
           }
           if (debug) console.log(`> ${command}`)
-          return execSync(command, {
-            cwd,
-            stdio: 'pipe',
-            ...childProcessOptions,
-          }).toString()
+          return new Promise((resolve, reject) => {
+            exec(
+              command,
+              {
+                cwd,
+                ...childProcessOptions,
+              },
+              (error, stdout, stderr) => {
+                if (error) {
+                  console.error(stderr)
+                  reject(error)
+                } else {
+                  resolve(stdout.toString())
+                }
+              },
+            )
+          })
         },
         async spawn(command: string, childProcessOptions: ChildProcessOptions = {}) {
           let resolveDisposal: (() => void) | undefined
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
@@ -4,56 +4,84 @@ import { clearRequireCache } from '@tailwindcss/node/require-cache'
 import { Scanner } from '@tailwindcss/oxide'
 import fixRelativePathsPlugin, { normalizePath } from 'internal-postcss-fix-relative-paths'
 import { Features, transform } from 'lightningcss'
+import fs from 'node:fs/promises'
 import path from 'path'
-import postcssrc from 'postcss-load-config'
+import postcss from 'postcss'
+import postcssImport from 'postcss-import'
 import type { Plugin, ResolvedConfig, Rollup, Update, ViteDevServer } from 'vite'
 
 export default function tailwindcss(): Plugin[] {
   let server: ViteDevServer | null = null
   let config: ResolvedConfig | null = null
-  let scanner: Scanner | null = null
-  let changedContent: { content: string; extension: string }[] = []
-  let candidates = new Set<string>()
-  let fullRebuildPaths: string[] = []
-
-  // In serve mode this is treated as a set — the content doesn't matter.
-  // In build mode, we store file contents to use them in renderChunk.
-  let cssModules: Record<
-    string,
-    {
-      content: string
-      handled: boolean
-    }
-  > = {}
+
   let isSSR = false
   let minify = false
+
+  // A list of css plugins defined in the Vite config. We need to retain these
+  // so that we can rerun the right transformations in build mode where we have
+  // to manually rebuild the css file after the compilation is done.
   let cssPlugins: readonly Plugin[] = []
 
-  // Trigger update to all CSS modules
-  function updateCssModules(isSSR: boolean) {
+  // The Vite extension has two types of sources for candidates:
+  //
+  // 1. The module graph: These are all modules that vite transforms and we want
+  //    them to be automatically scanned for candidates.
+  // 2. Root defined `@source`s
+  //
+  // Module graph candidates are global to the Vite extension since we do not
+  // know which CSS roots will be used for the modules. We are using a custom
+  // scanner instance with auto source discovery disabled to parse these.
+  //
+  // For candidates coming from custom `@source` directives of the CSS roots, we
+  // create an individual scanner for each root.
+  //
+  // Note: To improve performance, we do not remove candidates from this set.
+  // This means a longer-ongoing dev mode session might contain candidates that
+  // are no longer referenced in code.
+  let moduleGraphCandidates = new Set<string>()
+  let moduleGraphScanner = new Scanner({})
+
+  let roots: DefaultMap<string, Root> = new DefaultMap(
+    (id) => new Root(id, () => moduleGraphCandidates, config!.base),
+  )
+
+  function scanFile(id: string, content: string, extension: string, isSSR: boolean) {
+    let updated = false
+    for (let candidate of moduleGraphScanner.scanFiles([{ content, extension }])) {
+      updated = true
+      moduleGraphCandidates.add(candidate)
+    }
+
+    if (updated) {
+      invalidateAllRoots(isSSR)
+    }
+  }
+
+  function invalidateAllRoots(isSSR: boolean) {
     // If we're building then we don't need to update anything
     if (!server) return
 
     let updates: Update[] = []
-    for (let id of Object.keys(cssModules)) {
-      let cssModule = server.moduleGraph.getModuleById(id)
-      if (!cssModule) {
+    for (let id of roots.keys()) {
+      let module = server.moduleGraph.getModuleById(id)
+      if (!module) {
         // 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.
         if (!isSSR) {
           // It is safe to remove the item here since we're iterating on a copy
           // of the keys.
-          delete cssModules[id]
+          roots.delete(id)
         }
         continue
       }
 
-      server.moduleGraph.invalidateModule(cssModule)
+      roots.get(id).requiresRebuild = false
+      server.moduleGraph.invalidateModule(module)
       updates.push({
-        type: `${cssModule.type}-update`,
-        path: cssModule.url,
-        acceptedPath: cssModule.url,
+        type: `${module.type}-update`,
+        path: module.url,
+        acceptedPath: module.url,
         timestamp: Date.now(),
       })
     }
@@ -63,86 +91,13 @@ export default function tailwindcss(): Plugin[] {
     }
   }
 
-  function scan(src: string, extension: string) {
-    let updated = false
-
-    if (scanner === null) {
-      changedContent.push({ content: src, extension })
-      return updated
-    }
-
-    // Parse all candidates given the resolved files
-    for (let candidate of scanner.scanFiles([{ content: src, extension }])) {
-      updated = true
-      candidates.add(candidate)
-    }
-
-    return updated
-  }
-
-  async function generateCss(css: string, inputPath: string, addWatchFile: (file: string) => void) {
-    let inputBasePath = path.dirname(path.resolve(inputPath))
-    clearRequireCache(fullRebuildPaths)
-    fullRebuildPaths = []
-    let { build, globs } = await compile(css, {
-      base: inputBasePath,
-      onDependency(path) {
-        addWatchFile(path)
-        fullRebuildPaths.push(path)
-      },
-    })
-
-    scanner = new Scanner({
-      sources: globs.map(({ origin, pattern }) => ({
-        // Ensure the glob is relative to the input CSS file or the config file
-        // where it is specified.
-        base: origin ? path.dirname(path.resolve(inputBasePath, origin)) : inputBasePath,
-        pattern,
-      })),
-    })
-
-    // 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).
-    for (let candidate of scanner.scan()) {
-      candidates.add(candidate)
-    }
-
-    if (changedContent.length > 0) {
-      for (let candidate of scanner.scanFiles(changedContent.splice(0))) {
-        candidates.add(candidate)
-      }
-    }
-
-    // Watch individual files
-    for (let file of scanner.files) {
-      addWatchFile(file)
-    }
-
-    // Watch globs
-    for (let glob of scanner.globs) {
-      if (glob.pattern[0] === '!') continue
-
-      let relative = path.relative(config!.root, 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)
-
-      addWatchFile(path.posix.join(relative, glob.pattern))
+  async function regenerateOptimizedCss(root: Root, addWatchFile: (file: string) => void) {
+    let content = root.lastContent
+    let generated = await root.generate(content, addWatchFile)
+    if (generated === false) {
+      return
     }
-
-    return build(Array.from(candidates))
-  }
-
-  async function generateOptimizedCss(
-    css: string,
-    inputPath: string,
-    addWatchFile: (file: string) => void,
-  ) {
-    return optimizeCss(await generateCss(css, inputPath, addWatchFile), { minify })
+    return optimizeCss(generated, { minify })
   }
 
   // Manually run the transform functions of non-Tailwind plugins on the given CSS
@@ -208,108 +163,45 @@ export default function tailwindcss(): Plugin[] {
         })
       },
 
-      // Append the postcss-fix-relative-paths plugin
-      async config(config) {
-        let postcssConfig = config.css?.postcss
-
-        if (typeof postcssConfig === 'string') {
-          // We expand string configs to their PostCSS config object similar to
-          // how Vite does it.
-          // See: https://github.com/vitejs/vite/blob/440783953a55c6c63cd09ec8d13728dc4693073d/packages/vite/src/node/plugins/css.ts#L1580
-          let searchPath = typeof postcssConfig === 'string' ? postcssConfig : config.root
-          let parsedConfig = await postcssrc({}, searchPath).catch((e: Error) => {
-            if (!e.message.includes('No PostCSS Config found')) {
-              if (e instanceof Error) {
-                let { name, message, stack } = e
-                e.name = 'Failed to load PostCSS config'
-                e.message = `Failed to load PostCSS config (searchPath: ${searchPath}): [${name}] ${message}\n${stack}`
-                e.stack = '' // add stack to message to retain stack
-                throw e
-              } else {
-                throw new Error(`Failed to load PostCSS config: ${e}`)
-              }
-            }
-            return null
-          })
-          if (parsedConfig !== null) {
-            postcssConfig = {
-              options: parsedConfig.options,
-              plugins: parsedConfig.plugins,
-            } as any
-          } else {
-            postcssConfig = {}
-          }
-          config.css = { postcss: postcssConfig }
-        }
-
-        // postcssConfig is no longer a string after the above. This test is to
-        // avoid TypeScript errors below.
-        if (typeof postcssConfig === 'string') {
-          return
-        }
-
-        if (!postcssConfig || !postcssConfig?.plugins) {
-          config.css = config.css || {}
-          config.css.postcss = postcssConfig || {}
-          config.css.postcss.plugins = [fixRelativePathsPlugin() as any]
-        } else {
-          postcssConfig.plugins.push(fixRelativePathsPlugin() as any)
-        }
-      },
-
-      // Scan index.html for candidates
-      transformIndexHtml(html) {
-        let updated = scan(html, 'html')
-
-        // In serve mode, if the generated CSS contains a URL that causes the
-        // browser to load a page (e.g. an URL to a missing image), triggering a
-        // CSS update will cause an infinite loop. We only trigger if the
-        // candidates have been updated.
-        if (updated) {
-          updateCssModules(isSSR)
-        }
-      },
-
       // Scan all non-CSS files for candidates
+      transformIndexHtml(html, { path }) {
+        scanFile(path, html, 'html', isSSR)
+      },
       transform(src, id, options) {
-        if (id.includes('/.vite/')) return
         let extension = getExtension(id)
         if (extension === '' || extension === 'css') return
-
-        scan(src, extension)
-        updateCssModules(options?.ssr ?? false)
+        scanFile(id, src, extension, options?.ssr ?? false)
       },
     },
 
-    /*
-     * The plugins that generate CSS must run after 'enforce: pre' so @imports
-     * are expanded in transform.
-     */
-
     {
       // Step 2 (serve mode): Generate CSS
       name: '@tailwindcss/vite:generate:serve',
       apply: 'serve',
+      enforce: 'pre',
 
       async transform(src, id, options) {
-        if (!isTailwindCssFile(id, src)) return
+        if (!isPotentialCssRootFile(id)) return
 
-        // In serve mode, we treat cssModules as a set, ignoring the value.
-        cssModules[id] = { content: '', handled: true }
+        let root = roots.get(id)
 
         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
           // during SSR or it will block the server.
+          //
+          // The reason why we can not rely on the invalidation here is that the
+          // users would otherwise see a flicker in the styles as the CSS might
+          // be loaded with an invalid set of candidates first.
           await server?.waitForRequestsIdle?.(id)
         }
 
-        let code = await transformWithPlugins(
-          this,
-          id,
-          await generateCss(src, id, (file) => this.addWatchFile(file)),
-        )
-        return { code }
+        let generated = await root.generate(src, (file) => this.addWatchFile(file))
+        if (!generated) {
+          roots.delete(id)
+          return src
+        }
+        return { code: generated }
       },
     },
 
@@ -317,29 +209,48 @@ export default function tailwindcss(): Plugin[] {
       // Step 2 (full build): Generate CSS
       name: '@tailwindcss/vite:generate:build',
       apply: 'build',
+      enforce: 'pre',
+
+      async transform(src, id) {
+        // TODO: Check if this is also triggered by invalidateModule
+        if (!isPotentialCssRootFile(id)) return
 
-      transform(src, id) {
-        if (!isTailwindCssFile(id, src)) return
-        cssModules[id] = { content: src, handled: false }
+        let root = roots.get(id)
+
+        // 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.
+        let generated = await root.generate(src, (file) => this.addWatchFile(file))
+        if (!generated) {
+          roots.delete(id)
+          return src
+        }
+        return { code: generated }
       },
 
-      // renderChunk runs in the bundle generation stage after all transforms.
+      // `renderStart` runs in the bundle generation stage after all transforms.
       // We must run before `enforce: post` so the updated chunks are picked up
       // by vite:css-post.
-      async renderChunk(_code, _chunk) {
-        for (let [id, file] of Object.entries(cssModules)) {
-          if (file.handled) {
+      async renderStart() {
+        for (let [id, root] of roots.entries()) {
+          let generated = await regenerateOptimizedCss(
+            root,
+            // During the renderStart phase, we can not add watch files since
+            // those would not be causing a refresh of the right CSS file. This
+            // should not be an issue since we did already process the CSS file
+            // before and the dependencies should not be changed (only the
+            // candidate list might have)
+            () => {},
+          )
+          if (!generated) {
+            roots.delete(id)
             continue
           }
 
-          let css = await generateOptimizedCss(file.content, id, (file) => this.addWatchFile(file))
-
           // These plugins have side effects which, during build, results in CSS
           // being written to the output dir. We need to run them here to ensure
           // the CSS is written before the bundle is generated.
-          await transformWithPlugins(this, id, css)
-
-          file.handled = true
+          await transformWithPlugins(this, id, generated)
         }
       },
     },
@@ -351,10 +262,22 @@ function getExtension(id: string) {
   return path.extname(filename).slice(1)
 }
 
-function isTailwindCssFile(id: string, src: string) {
+function isPotentialCssRootFile(id: string) {
   let extension = getExtension(id)
   let isCssFile = extension === 'css' || (extension === 'vue' && id.includes('&lang.css'))
-  return isCssFile && src.includes('@tailwind')
+  return isCssFile
+}
+
+function isCssRootFile(content: string) {
+  return (
+    content.includes('@tailwind') ||
+    content.includes('@config') ||
+    content.includes('@plugin') ||
+    content.includes('@apply') ||
+    content.includes('@theme') ||
+    content.includes('@variant') ||
+    content.includes('@utility')
+  )
 }
 
 function optimizeCss(
@@ -380,3 +303,150 @@ function optimizeCss(
     errorRecovery: true,
   }).code.toString()
 }
+
+function idToPath(id: string) {
+  return path.resolve(id.replace(/\?.*$/, ''))
+}
+
+/**
+ * A Map that can generate default values for keys that don't exist.
+ * Generated default values are added to the map to avoid recomputation.
+ */
+class DefaultMap<K, V> extends Map<K, V> {
+  constructor(private factory: (key: K, self: DefaultMap<K, V>) => V) {
+    super()
+  }
+
+  get(key: K): V {
+    let value = super.get(key)
+
+    if (value === undefined) {
+      value = this.factory(key, this)
+      this.set(key, value)
+    }
+
+    return value
+  }
+}
+
+class Root {
+  // Content is only used in serve mode where we need to capture the initial
+  // contents of the root file so that we can restore it during the
+  // `renderStart` hook.
+  public lastContent: string = ''
+
+  // The lazily-initialized Tailwind compiler components. These are persisted
+  // throughout rebuilds but will be re-initialized if the rebuild strategy is
+  // set to `full`.
+  private compiler?: Awaited<ReturnType<typeof compile>>
+
+  public requiresRebuild: boolean = true
+
+  // This is the compiler-specific scanner instance that is used only to scan
+  // files for custom @source paths. All other modules we scan for candidates
+  // will use the shared moduleGraphScanner instance.
+  private scanner?: Scanner
+
+  // List of all candidates that were being returned by the root scanner during
+  // the lifetime of the root.
+  private candidates: Set<string> = new Set<string>()
+
+  // List of all file dependencies that were captured while generating the root.
+  // These are retained so we can clear the require cache when we rebuild the
+  // root.
+  private dependencies = new Set<string>()
+
+  constructor(
+    private id: string,
+    private getSharedCandidates: () => Set<string>,
+    private base: string,
+  ) {}
+
+  // Generate the CSS for the root file. This can return false if the file is
+  // not considered a Tailwind root. When this happened, the root can be GCed.
+  public async generate(
+    content: string,
+    addWatchFile: (file: string) => void,
+  ): Promise<string | false> {
+    this.lastContent = content
+
+    let inputPath = idToPath(this.id)
+    let inputBase = path.dirname(path.resolve(inputPath))
+
+    if (!this.compiler || !this.scanner || this.requiresRebuild) {
+      clearRequireCache(Array.from(this.dependencies))
+      this.dependencies = new Set([idToPath(inputPath)])
+
+      let postcssCompiled = await postcss([
+        postcssImport({
+          load: (path) => {
+            this.dependencies.add(path)
+            addWatchFile(path)
+            return fs.readFile(path, 'utf8')
+          },
+        }),
+        fixRelativePathsPlugin(),
+      ]).process(content, {
+        from: inputPath,
+        to: inputPath,
+      })
+      let css = postcssCompiled.css
+
+      // This is done inside the Root#generate() method so that we can later use
+      // information from the Tailwind compiler to determine if the file is a
+      // CSS root (necessary because we will probably inline the `@import`
+      // resolution at some point).
+      if (!isCssRootFile(css)) {
+        return false
+      }
+
+      this.compiler = await compile(css, {
+        base: inputBase,
+        onDependency: (path) => {
+          addWatchFile(path)
+          this.dependencies.add(path)
+        },
+      })
+
+      this.scanner = new Scanner({
+        sources: this.compiler.globs.map(({ origin, pattern }) => ({
+          // Ensure the glob is relative to the input CSS file or the config
+          // file where it is specified.
+          base: origin ? path.dirname(path.resolve(inputBase, origin)) : inputBase,
+          pattern,
+        })),
+      })
+    }
+
+    // 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).
+    for (let candidate of this.scanner.scan()) {
+      this.candidates.add(candidate)
+    }
+
+    // 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
+
+      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)
+
+      addWatchFile(path.posix.join(relative, glob.pattern))
+    }
+
+    this.requiresRebuild = true
+
+    return this.compiler.build([...this.getSharedCandidates(), ...this.candidates])
+  }
+}


Test patch:
diff --git a/integrations/vite/index.test.ts b/integrations/vite/index.test.ts
--- a/integrations/vite/index.test.ts
+++ b/integrations/vite/index.test.ts
@@ -1,5 +1,5 @@
 import path from 'node:path'
-import { expect } from 'vitest'
+import { describe, expect } from 'vitest'
 import {
   candidate,
   css,
@@ -12,93 +12,384 @@ import {
   ts,
   yaml,
 } from '../utils'
+;['postcss', 'lightningcss'].forEach((transformer) => {
+  describe(transformer, () => {
+    test(
+      `production build`,
+      {
+        fs: {
+          'package.json': json`{}`,
+          'pnpm-workspace.yaml': yaml`
+            #
+            packages:
+              - project-a
+          `,
+          'project-a/package.json': json`
+            {
+              "type": "module",
+              "dependencies": {
+                "@tailwindcss/vite": "workspace:^",
+                "tailwindcss": "workspace:^"
+              },
+              "devDependencies": {
+                ${transformer === 'lightningcss' ? `"lightningcss": "^1.26.0",` : ''}
+                "vite": "^5.3.5"
+              }
+            }
+          `,
+          'project-a/vite.config.ts': ts`
+            import tailwindcss from '@tailwindcss/vite'
+            import { defineConfig } from 'vite'
 
-test(
-  'production build',
-  {
-    fs: {
-      'package.json': json`{}`,
-      'pnpm-workspace.yaml': yaml`
-        #
-        packages:
-          - project-a
-      `,
-      'project-a/package.json': json`
-        {
-          "type": "module",
-          "dependencies": {
-            "@tailwindcss/vite": "workspace:^",
-            "tailwindcss": "workspace:^"
-          },
-          "devDependencies": {
-            "vite": "^5.3.5"
-          }
-        }
-      `,
-      'project-a/vite.config.ts': ts`
-        import tailwindcss from '@tailwindcss/vite'
-        import { defineConfig } from 'vite'
+            export default defineConfig({
+              css: ${transformer === 'postcss' ? '{}' : "{ transformer: 'lightningcss' }"},
+              build: { cssMinify: false },
+              plugins: [tailwindcss()],
+            })
+          `,
+          'project-a/index.html': html`
+            <head>
+              <link rel="stylesheet" href="./src/index.css" />
+            </head>
+            <body>
+              <div class="underline m-2">Hello, world!</div>
+            </body>
+          `,
+          'project-a/tailwind.config.js': js`
+            export default {
+              content: ['../project-b/src/**/*.js'],
+            }
+          `,
+          'project-a/src/index.css': css`
+            @import 'tailwindcss/theme' theme(reference);
+            @import 'tailwindcss/utilities';
+            @config '../tailwind.config.js';
+            @source '../../project-b/src/**/*.html';
+          `,
+          'project-b/src/index.html': html`
+            <div class="flex" />
+          `,
+          'project-b/src/index.js': js`
+            const className = "content-['project-b/src/index.js']"
+            module.exports = { className }
+          `,
+        },
+      },
+      async ({ root, fs, exec }) => {
+        await exec('pnpm vite build', { cwd: path.join(root, 'project-a') })
 
-        export default defineConfig({
-          build: { cssMinify: false },
-          plugins: [tailwindcss()],
+        let files = await fs.glob('project-a/dist/**/*.css')
+        expect(files).toHaveLength(1)
+        let [filename] = files[0]
+
+        await fs.expectFileToContain(filename, [
+          candidate`underline`,
+          candidate`m-2`,
+          candidate`flex`,
+          candidate`content-['project-b/src/index.js']`,
+        ])
+      },
+    )
+
+    test(
+      `dev mode`,
+      {
+        fs: {
+          'package.json': json`{}`,
+          'pnpm-workspace.yaml': yaml`
+            #
+            packages:
+              - project-a
+          `,
+          'project-a/package.json': json`
+            {
+              "type": "module",
+              "dependencies": {
+                "@tailwindcss/vite": "workspace:^",
+                "tailwindcss": "workspace:^"
+              },
+              "devDependencies": {
+                "vite": "^5.3.5"
+              }
+            }
+          `,
+          'project-a/vite.config.ts': ts`
+            import tailwindcss from '@tailwindcss/vite'
+            import { defineConfig } from 'vite'
+
+            export default defineConfig({
+              css: ${transformer === 'postcss' ? '{}' : "{ transformer: 'lightningcss' }"},
+              build: { cssMinify: false },
+              plugins: [tailwindcss()],
+            })
+          `,
+          'project-a/index.html': html`
+            <head>
+              <link rel="stylesheet" href="./src/index.css" />
+            </head>
+            <body>
+              <div class="underline">Hello, world!</div>
+            </body>
+          `,
+          'project-a/about.html': html`
+            <head>
+              <link rel="stylesheet" href="./src/index.css" />
+            </head>
+            <body>
+              <div class="font-bold">Tailwind Labs</div>
+            </body>
+          `,
+          'project-a/tailwind.config.js': js`
+            export default {
+              content: ['../project-b/src/**/*.js'],
+            }
+          `,
+          'project-a/src/index.css': css`
+            @import 'tailwindcss/theme' theme(reference);
+            @import 'tailwindcss/utilities';
+            @config '../tailwind.config.js';
+            @source '../../project-b/src/**/*.html';
+          `,
+          'project-b/src/index.html': html`
+            <div class="flex" />
+          `,
+          'project-b/src/index.js': js`
+            const className = "content-['project-b/src/index.js']"
+            module.exports = { className }
+          `,
+        },
+      },
+      async ({ root, spawn, getFreePort, fs }) => {
+        let port = await getFreePort()
+        await spawn(`pnpm vite dev --port ${port}`, {
+          cwd: path.join(root, 'project-a'),
+        })
+
+        // Candidates are resolved lazily, so the first visit of index.html
+        // will only have candidates from this file.
+        await retryAssertion(async () => {
+          let css = await fetchStyles(port, '/index.html')
+          expect(css).toContain(candidate`underline`)
+          expect(css).toContain(candidate`flex`)
+          expect(css).not.toContain(candidate`font-bold`)
+        })
+
+        // Going to about.html will extend the candidate list to include
+        // candidates from about.html.
+        await retryAssertion(async () => {
+          let css = await fetchStyles(port, '/about.html')
+          expect(css).toContain(candidate`underline`)
+          expect(css).toContain(candidate`flex`)
+          expect(css).toContain(candidate`font-bold`)
         })
-      `,
-      'project-a/index.html': html`
-        <head>
-          <link rel="stylesheet" href="./src/index.css" />
-        </head>
-        <body>
-          <div class="underline m-2">Hello, world!</div>
-        </body>
-      `,
-      'project-a/tailwind.config.js': js`
-        export default {
-          content: ['../project-b/src/**/*.js'],
-        }
-      `,
-      'project-a/src/index.css': css`
-        @import 'tailwindcss/theme' theme(reference);
-        @import 'tailwindcss/utilities';
-        @config '../tailwind.config.js';
-        @source '../../project-b/src/**/*.html';
-      `,
-      'project-b/src/index.html': html`
-        <div class="flex" />
-      `,
-      'project-b/src/index.js': js`
-        const className = "content-['project-b/src/index.js']"
-        module.exports = { className }
-      `,
-    },
-  },
-  async ({ root, fs, exec }) => {
-    await exec('pnpm vite build', { cwd: path.join(root, 'project-a') })
-
-    let files = await fs.glob('project-a/dist/**/*.css')
-    expect(files).toHaveLength(1)
-    let [filename] = files[0]
-
-    await fs.expectFileToContain(filename, [
-      candidate`underline`,
-      candidate`flex`,
-      candidate`m-2`,
-      candidate`content-['project-b/src/index.js']`,
-    ])
-  },
-)
+
+        // Updates are additive and cause new candidates to be added.
+        await fs.write(
+          'project-a/index.html',
+          html`
+            <head>
+              <link rel="stylesheet" href="./src/index.css" />
+            </head>
+            <body>
+              <div class="underline m-2">Hello, world!</div>
+            </body>
+          `,
+        )
+        await retryAssertion(async () => {
+          let css = await fetchStyles(port)
+          expect(css).toContain(candidate`underline`)
+          expect(css).toContain(candidate`flex`)
+          expect(css).toContain(candidate`font-bold`)
+          expect(css).toContain(candidate`m-2`)
+        })
+
+        // Manually added `@source`s are watched and trigger a rebuild
+        await fs.write(
+          'project-b/src/index.js',
+          js`
+            const className = "[.changed_&]:content-['project-b/src/index.js']"
+            module.exports = { className }
+          `,
+        )
+        await retryAssertion(async () => {
+          let css = await fetchStyles(port)
+          expect(css).toContain(candidate`underline`)
+          expect(css).toContain(candidate`flex`)
+          expect(css).toContain(candidate`font-bold`)
+          expect(css).toContain(candidate`m-2`)
+          expect(css).toContain(candidate`[.changed_&]:content-['project-b/src/index.js']`)
+        })
+
+        // After updates to the CSS file, all previous candidates should still be in
+        // the generated CSS
+        await fs.write(
+          'project-a/src/index.css',
+          css`
+            ${await fs.read('project-a/src/index.css')}
+
+            .red {
+              color: red;
+            }
+          `,
+        )
+        await retryAssertion(async () => {
+          let css = await fetchStyles(port)
+          expect(css).toContain(candidate`red`)
+          expect(css).toContain(candidate`flex`)
+          expect(css).toContain(candidate`m-2`)
+          expect(css).toContain(candidate`underline`)
+          expect(css).toContain(candidate`[.changed_&]:content-['project-b/src/index.js']`)
+          expect(css).toContain(candidate`font-bold`)
+        })
+      },
+    )
+
+    test(
+      'watch mode',
+      {
+        fs: {
+          'package.json': json`{}`,
+          'pnpm-workspace.yaml': yaml`
+            #
+            packages:
+              - project-a
+          `,
+          'project-a/package.json': json`
+            {
+              "type": "module",
+              "dependencies": {
+                "@tailwindcss/vite": "workspace:^",
+                "tailwindcss": "workspace:^"
+              },
+              "devDependencies": {
+                "vite": "^5.3.5"
+              }
+            }
+          `,
+          'project-a/vite.config.ts': ts`
+            import tailwindcss from '@tailwindcss/vite'
+            import { defineConfig } from 'vite'
+
+            export default defineConfig({
+              build: { cssMinify: false },
+              plugins: [tailwindcss()],
+            })
+          `,
+          'project-a/index.html': html`
+            <head>
+              <link rel="stylesheet" href="./src/index.css" />
+            </head>
+            <body>
+              <div class="underline">Hello, world!</div>
+            </body>
+          `,
+          'project-a/tailwind.config.js': js`
+            export default {
+              content: ['../project-b/src/**/*.js'],
+            }
+          `,
+          'project-a/src/index.css': css`
+            @import 'tailwindcss/theme' theme(reference);
+            @import 'tailwindcss/utilities';
+            @config '../tailwind.config.js';
+            @source '../../project-b/src/**/*.html';
+          `,
+          'project-b/src/index.html': html`
+            <div class="flex" />
+          `,
+          'project-b/src/index.js': js`
+            const className = "content-['project-b/src/index.js']"
+            module.exports = { className }
+          `,
+        },
+      },
+      async ({ root, spawn, fs }) => {
+        await spawn(`pnpm vite build --watch`, {
+          cwd: path.join(root, 'project-a'),
+        })
+
+        let filename = ''
+        await retryAssertion(async () => {
+          let files = await fs.glob('project-a/dist/**/*.css')
+          expect(files).toHaveLength(1)
+          filename = files[0][0]
+        })
+
+        await fs.expectFileToContain(filename, [candidate`underline`, candidate`flex`])
+
+        await new Promise((resolve) => setTimeout(resolve, 1000))
+
+        // Updates are additive and cause new candidates to be added.
+        await fs.write(
+          'project-a/index.html',
+          html`
+            <head>
+              <link rel="stylesheet" href="./src/index.css" />
+            </head>
+            <body>
+              <div class="underline m-2">Hello, world!</div>
+            </body>
+          `,
+        )
+        await retryAssertion(async () => {
+          let files = await fs.glob('project-a/dist/**/*.css')
+          expect(files).toHaveLength(1)
+          let [, css] = files[0]
+          expect(css).toContain(candidate`underline`)
+          expect(css).toContain(candidate`flex`)
+          expect(css).toContain(candidate`m-2`)
+        })
+
+        // Manually added `@source`s are watched and trigger a rebuild
+        await fs.write(
+          'project-b/src/index.js',
+          js`
+            const className = "[.changed_&]:content-['project-b/src/index.js']"
+            module.exports = { className }
+          `,
+        )
+        await retryAssertion(async () => {
+          let files = await fs.glob('project-a/dist/**/*.css')
+          expect(files).toHaveLength(1)
+          let [, css] = files[0]
+          expect(css).toContain(candidate`underline`)
+          expect(css).toContain(candidate`flex`)
+          expect(css).toContain(candidate`m-2`)
+          expect(css).toContain(candidate`[.changed_&]:content-['project-b/src/index.js']`)
+        })
+
+        // After updates to the CSS file, all previous candidates should still be in
+        // the generated CSS
+        await fs.write(
+          'project-a/src/index.css',
+          css`
+            ${await fs.read('project-a/src/index.css')}
+
+            .red {
+              color: red;
+            }
+          `,
+        )
+        await retryAssertion(async () => {
+          let files = await fs.glob('project-a/dist/**/*.css')
+          expect(files).toHaveLength(1)
+          let [, css] = files[0]
+          expect(css).toContain(candidate`underline`)
+          expect(css).toContain(candidate`flex`)
+          expect(css).toContain(candidate`m-2`)
+          expect(css).toContain(candidate`[.changed_&]:content-['project-b/src/index.js']`)
+          expect(css).toContain(candidate`red`)
+        })
+      },
+    )
+  })
+})
 
 test(
-  'dev mode',
+  `demote Tailwind roots to regular CSS files and back to Tailwind roots while restoring all candidates`,
   {
     fs: {
-      'package.json': json`{}`,
-      'pnpm-workspace.yaml': yaml`
-        #
-        packages:
-          - project-a
-      `,
-      'project-a/package.json': json`
+      'package.json': json`
         {
           "type": "module",
           "dependencies": {
@@ -110,7 +401,7 @@ test(
           }
         }
       `,
-      'project-a/vite.config.ts': ts`
+      'vite.config.ts': ts`
         import tailwindcss from '@tailwindcss/vite'
         import { defineConfig } from 'vite'
 
@@ -119,7 +410,7 @@ test(
           plugins: [tailwindcss()],
         })
       `,
-      'project-a/index.html': html`
+      'index.html': html`
         <head>
           <link rel="stylesheet" href="./src/index.css" />
         </head>
@@ -127,46 +418,26 @@ test(
           <div class="underline">Hello, world!</div>
         </body>
       `,
-      'project-a/about.html': html`
+      'about.html': html`
         <head>
           <link rel="stylesheet" href="./src/index.css" />
         </head>
         <body>
-          <div class="font-bold	">Tailwind Labs</div>
+          <div class="font-bold">Tailwind Labs</div>
         </body>
       `,
-      'project-a/tailwind.config.js': js`
-        export default {
-          content: ['../project-b/src/**/*.js'],
-        }
-      `,
-      'project-a/src/index.css': css`
-        @import 'tailwindcss/theme' theme(reference);
-        @import 'tailwindcss/utilities';
-        @config '../tailwind.config.js';
-        @source '../../project-b/src/**/*.html';
-      `,
-      'project-b/src/index.html': html`
-        <div class="flex" />
-      `,
-      'project-b/src/index.js': js`
-        const className = "content-['project-b/src/index.js']"
-        module.exports = { className }
-      `,
+      'src/index.css': css`@import 'tailwindcss';`,
     },
   },
-  async ({ root, spawn, getFreePort, fs }) => {
+  async ({ spawn, getFreePort, fs }) => {
     let port = await getFreePort()
-    await spawn(`pnpm vite dev --port ${port}`, {
-      cwd: path.join(root, 'project-a'),
-    })
+    await spawn(`pnpm vite dev --port ${port}`)
 
     // Candidates are resolved lazily, so the first visit of index.html
     // will only have candidates from this file.
     await retryAssertion(async () => {
       let css = await fetchStyles(port, '/index.html')
       expect(css).toContain(candidate`underline`)
-      expect(css).toContain(candidate`flex`)
       expect(css).not.toContain(candidate`font-bold`)
     })
 
@@ -175,63 +446,14 @@ test(
     await retryAssertion(async () => {
       let css = await fetchStyles(port, '/about.html')
       expect(css).toContain(candidate`underline`)
-      expect(css).toContain(candidate`flex`)
       expect(css).toContain(candidate`font-bold`)
     })
 
-    // Updates are additive and cause new candidates to be added.
-    await fs.write(
-      'project-a/index.html',
-      html`
-        <head>
-          <link rel="stylesheet" href="./src/index.css" />
-        </head>
-        <body>
-          <div class="underline m-2">Hello, world!</div>
-        </body>
-      `,
-    )
-    await retryAssertion(async () => {
-      let css = await fetchStyles(port)
-      expect(css).toContain(candidate`underline`)
-      expect(css).toContain(candidate`font-bold`)
-      expect(css).toContain(candidate`m-2`)
-    })
-
-    // Manually added `@source`s are watched and trigger a rebuild
-    await fs.write(
-      'project-b/src/index.js',
-      js`
-        const className = "[.changed_&]:content-['project-b/src/index.js']"
-        module.exports = { className }
-      `,
-    )
-    await retryAssertion(async () => {
-      let css = await fetchStyles(port)
-      expect(css).toContain(candidate`underline`)
-      expect(css).toContain(candidate`font-bold`)
-      expect(css).toContain(candidate`m-2`)
-      expect(css).toContain(candidate`[.changed_&]:content-['project-b/src/index.js']`)
-    })
-
-    // After updates to the CSS file, all previous candidates should still be in
-    // the generated CSS
-    await fs.write(
-      'project-a/src/index.css',
-      css`
-        ${await fs.read('project-a/src/index.css')}
-
-        .red {
-          color: red;
-        }
-      `,
-    )
+    // We change the CSS file so it is no longer a valid Tailwind root.
+    await fs.write('src/index.css', css`@import 'tailwindcss';`)
     await retryAssertion(async () => {
       let css = await fetchStyles(port)
-      expect(css).toContain(candidate`red`)
-      expect(css).toContain(candidate`m-2`)
       expect(css).toContain(candidate`underline`)
-      expect(css).toContain(candidate`[.changed_&]:content-['project-b/src/index.js']`)
       expect(css).toContain(candidate`font-bold`)
     })
   },