Repository: tailwindlabs/tailwindcss

Problem statement:
Vue static vdom stringifier breaks PostCSS in Nuxt Production build
<!-- Please provide all of the information requested below. We're a small team and without all of this information it's not possible for us to help and your bug report will be closed. -->

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

v4.0.4

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

nuxt@3.15.4

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

v20.11.0

**What browser are you using?**

N/A

**What operating system are you using?**

Ubuntu 20.04.6 LTS (in Windows Subsystem for Linux)

**Reproduction URL**

https://github.com/wongjn/tailwind-nuxt

**Describe your issue**

See my original debugging journey at https://github.com/tailwindlabs/tailwindcss/discussions/15818#discussioncomment-12098822

Perhaps related to https://github.com/tailwindlabs/tailwindcss/issues/16133 in terms of HTML entity encoding causing the issue.

When you have a template with enough elements with props on them, i.e:

```html
<script setup lang="ts">
const includedFeatures = ["Foo", "Bar", "Baz"];
</script>

<template>
  <main class="" lang="de">
    <div class="">
      <div class="">
        <div class="">
          <div class="">
            <img class="" src="" alt="" />
          </div>
        </div>
      </div>
    </div>
  </main>
</template>
```

It [passes a threshold in Vue's compiler to compile the template as a "static" VNode](https://github.com/vuejs/core/blob/119f18c773b8d437fada3e0b8b847a282182c8dd/packages/compiler-dom/src/transforms/stringifyStatic.ts#L93-L106):

```js
import { defineComponent as _defineComponent } from 'vue'
import { createElementVNode as _createElementVNode, createStaticVNode as _createStaticVNode, openBlock as _openBlock, createElementBlock as _createElementBlock } from "vue"

const _hoisted_1 = {
  class: "",
  lang: "de"
}


export default /*@__PURE__*/_defineComponent({
  __name: 'index',
  setup(__props) {

const includedFeatures = ["Foo", "Bar", "Baz"];

return (_ctx: any,_cache: any) => {
  return (_openBlock(), _createElementBlock("main", _hoisted_1, _cache[0] || (_cache[0] = [
    _createStaticVNode("<div class=\\"\\"><div class=\\"\\"><div class=\\"\\"><div class=\\"\\"><img class=\\"\\" src=\\"\\" alt=\\"\\"></div></div></div></div>", 1)
  ])))
}
}

})
```

However, if we have a valid Tailwind class in there:

```diff
 <div class="">
-  <div class="">
+  <div class="after:content-['']">
     <img class="" src="" alt="" />
```

Then Vue compiles it to (snipped for brevity):

```js
return (_ctx: any,_cache: any) => {
  return (_openBlock(), _createElementBlock("main", _hoisted_1, _cache[0] || (_cache[0] = [
    _createStaticVNode("<div class=\\"\\"><div class=\\"\\"><div class=\\"after:content-[&#39;&#39;]\\"><div class=\\"\\"><img class=\\"\\" src=\\"\\" alt=\\"\\"></div></div></div></div>", 1)
  ])))
}
}
```
Notice the `after:content-[&#39;&#39;]`. Since this is a module in Vite's module graph, this gets scanned for class candidates by Tailwind. It sees `after:content-[&#39;&#39;]` as a valid class and generates a rule for it:

```css
.after\:content-\[\&\#39\;\&\#39\;\] {
  &::after {
    content: var(--tw-content);
    --tw-content: &#39;&#39;;
    content: var(--tw-content);
  }
}
```
And then when the default PostCSS pipeline in Nuxt runs on this, it errors out:
```
 ERROR  Nuxt Build Error: [vite:css] [postcss] /path/to/project/assets/css/main.css:128:24: Unknown word                                                                                  nuxi 11:01:10 PM
file: /path/to/project/assets/css/main.css:128:23

    file: assets/css/main.css:128:23
    at Input.error (node_modules/postcss/lib/input.js:109:16)
    at Parser.unknownWord (node_modules/postcss/lib/parser.js:593:22)
    at Parser.other (node_modules/postcss/lib/parser.js:435:12)
    at Parser.parse (node_modules/postcss/lib/parser.js:470:16)
    at parse (node_modules/postcss/lib/parse.js:11:12)
    at new LazyResult (node_modules/postcss/lib/lazy-result.js:133:16)
    at Processor.process (node_modules/postcss/lib/processor.js:53:14)
    at compileCSS (node_modules/vite/dist/node/chunks/dep-M1IYMR16.js:48784:59)
    at async Object.transform (node_modules/vite/dist/node/chunks/dep-M1IYMR16.js:48039:11)
    at async transform (node_modules/rollup/dist/es/shared/node-entry.js:19787:16)
```



Hints:

Patch:
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,15 +4,13 @@ import { Scanner } from '@tailwindcss/oxide'
 import { Features as LightningCssFeatures, transform } from 'lightningcss'
 import fs from 'node:fs/promises'
 import path from 'node:path'
-import type { Plugin, ResolvedConfig, Rollup, Update, ViteDevServer } from 'vite'
+import type { Plugin, ResolvedConfig, ViteDevServer } from 'vite'
 
 const DEBUG = env.DEBUG
 const SPECIAL_QUERY_RE = /[?&](?:worker|sharedworker|raw|url)\b/
 const COMMON_JS_PROXY_RE = /\?commonjs-proxy/
 const INLINE_STYLE_ID_RE = /[?&]index\=\d+\.css$/
 
-const IGNORED_DEPENDENCIES = ['tailwind-merge']
-
 export default function tailwindcss(): Plugin[] {
   let servers: ViteDevServer[] = []
   let config: ResolvedConfig | null = null
@@ -20,25 +18,6 @@ export default function tailwindcss(): Plugin[] {
   let isSSR = false
   let minify = false
 
-  // 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 DefaultMap<string, Set<string>>(() => new Set<string>())
-  let moduleGraphScanner = new Scanner({})
-
   let roots: DefaultMap<string, Root> = new DefaultMap((id) => {
     let cssResolver = config!.createResolver({
       ...config!.resolve,
@@ -56,133 +35,9 @@ export default function tailwindcss(): Plugin[] {
     function customJsResolver(id: string, base: string) {
       return jsResolver(id, base, true, isSSR)
     }
-    return new Root(
-      id,
-      () => moduleGraphCandidates,
-      config!.base,
-      customCssResolver,
-      customJsResolver,
-    )
+    return new Root(id, config!.root, customCssResolver, customJsResolver)
   })
 
-  function scanFile(id: string, content: string, extension: string) {
-    for (let dependency of IGNORED_DEPENDENCIES) {
-      // We validated that Vite IDs always use posix style path separators, even on Windows.
-      // In dev build, Vite precompiles dependencies
-      if (id.includes(`.vite/deps/${dependency}.js`)) {
-        return
-      }
-      // In prod builds, use the node_modules path
-      if (id.includes(`/node_modules/${dependency}/`)) {
-        return
-      }
-    }
-
-    let updated = false
-    for (let candidate of moduleGraphScanner.scanFiles([{ content, extension }])) {
-      updated = true
-      moduleGraphCandidates.get(id).add(candidate)
-    }
-
-    if (updated) {
-      invalidateAllRoots()
-    }
-  }
-
-  function invalidateAllRoots() {
-    for (let server of servers) {
-      let updates: Update[] = []
-      for (let [id] of roots.entries()) {
-        let module = server.moduleGraph.getModuleById(id)
-        if (!module) continue
-
-        roots.get(id).requiresRebuild = false
-        server.moduleGraph.invalidateModule(module)
-        updates.push({
-          type: `${module.type}-update`,
-          path: module.url,
-          acceptedPath: module.url,
-          timestamp: Date.now(),
-        })
-      }
-      if (updates.length > 0) {
-        server.hot.send({ type: 'update', updates })
-      }
-    }
-  }
-
-  async function regenerateOptimizedCss(
-    root: Root,
-    addWatchFile: (file: string) => void,
-    I: Instrumentation,
-  ) {
-    let content = root.lastContent
-    let generated = await root.generate(content, addWatchFile, I)
-    if (generated === false) {
-      return
-    }
-    DEBUG && I.start('Optimize CSS')
-    let result = optimizeCss(generated, { minify })
-    DEBUG && I.end('Optimize CSS')
-    return result
-  }
-
-  // Manually run the transform functions of non-Tailwind plugins on the given CSS
-  async function transformWithPlugins(context: Rollup.PluginContext, id: string, css: string) {
-    let transformPluginContext = {
-      ...context,
-      getCombinedSourcemap: () => {
-        throw new Error('getCombinedSourcemap not implemented')
-      },
-    }
-
-    for (let plugin of config!.plugins) {
-      if (!plugin.transform) continue
-
-      if (plugin.name.startsWith('@tailwindcss/')) {
-        // We do not run any Tailwind transforms anymore
-        continue
-      } else if (
-        plugin.name.startsWith('vite:') &&
-        // Apply the vite:css plugin to generated CSS for transformations like
-        // URL path rewriting and image inlining.
-        plugin.name !== 'vite:css' &&
-        // In build mode, since `renderStart` runs after all transformations, we
-        // need to also apply vite:css-post.
-        plugin.name !== 'vite:css-post' &&
-        // The vite:vue plugin handles CSS specific post-processing for Vue
-        plugin.name !== 'vite:vue'
-      ) {
-        continue
-      } else if (plugin.name === 'ssr-styles') {
-        // The Nuxt ssr-styles plugin emits styles from server-side rendered
-        // components, we can't run it in the `renderStart` phase so we're
-        // skipping it.
-        continue
-      }
-
-      let transformHandler =
-        'handler' in plugin.transform! ? plugin.transform.handler : plugin.transform!
-
-      try {
-        // Directly call the plugin's transform function to process the
-        // generated CSS. In build mode, this updates the chunks later used to
-        // generate the bundle. In serve mode, the transformed source should be
-        // applied in transform.
-        let result = await transformHandler.call(transformPluginContext, css, id)
-        if (!result) continue
-        if (typeof result === 'string') {
-          css = result
-        } else if (result.code) {
-          css = result.code
-        }
-      } catch (e) {
-        console.error(`Error running ${plugin.name} on Tailwind CSS output. Skipping.`)
-      }
-    }
-    return css
-  }
-
   return [
     {
       // Step 1: Scan source files for candidates
@@ -198,19 +53,6 @@ export default function tailwindcss(): Plugin[] {
         minify = config.build.cssMinify !== false
         isSSR = config.build.ssr !== false && config.build.ssr !== undefined
       },
-
-      // Scan all non-CSS files for candidates
-      transformIndexHtml(html, { path }) {
-        // SolidStart emits HTML chunks with an undefined path and the html content of `\`.
-        if (!path) return
-
-        scanFile(path, html, 'html')
-      },
-      transform(src, id, options) {
-        let extension = getExtension(id)
-        if (isPotentialCssRootFile(id)) return
-        scanFile(id, src, extension)
-      },
     },
 
     {
@@ -223,26 +65,17 @@ export default function tailwindcss(): Plugin[] {
         if (!isPotentialCssRootFile(id)) return
 
         using I = new Instrumentation()
-        I.start('[@tailwindcss/vite] Generate CSS (serve)')
+        DEBUG && I.start('[@tailwindcss/vite] Generate CSS (serve)')
 
         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 Promise.all(servers.map((server) => server.waitForRequestsIdle(id)))
-        }
-
         let generated = await root.generate(src, (file) => this.addWatchFile(file), I)
         if (!generated) {
           roots.delete(id)
           return src
         }
+
+        DEBUG && I.end('[@tailwindcss/vite] Generate CSS (serve)')
         return { code: generated }
       },
     },
@@ -257,49 +90,22 @@ export default function tailwindcss(): Plugin[] {
         if (!isPotentialCssRootFile(id)) return
 
         using I = new Instrumentation()
-        I.start('[@tailwindcss/vite] Generate CSS (build)')
+        DEBUG && I.start('[@tailwindcss/vite] Generate CSS (build)')
 
         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), I)
         if (!generated) {
           roots.delete(id)
           return src
         }
-        return { code: generated }
-      },
+        DEBUG && I.end('[@tailwindcss/vite] Generate CSS (build)')
 
-      // `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 renderStart() {
-        using I = new Instrumentation()
-        I.start('[@tailwindcss/vite] (render start)')
-
-        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)
-            () => {},
-            I,
-          )
-          if (!generated) {
-            roots.delete(id)
-            continue
-          }
+        DEBUG && I.start('[@tailwindcss/vite] Optimize CSS')
+        generated = optimizeCss(generated, { minify })
+        DEBUG && I.end('[@tailwindcss/vite] Optimize CSS')
 
-          // 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, generated)
-        }
+        return { code: generated }
       },
     },
   ] satisfies Plugin[]
@@ -325,7 +131,7 @@ function optimizeCss(
   input: string,
   { file = 'input.css', minify = false }: { file?: string; minify?: boolean } = {},
 ) {
-  function optimize(code: Buffer | Uint8Array) {
+  function optimize(code: Buffer | Uint8Array | any) {
     return transform({
       filename: file,
       code,
@@ -383,39 +189,24 @@ class DefaultMap<K, V> extends Map<K, V> {
 }
 
 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.
+  // The lazily-initialized Tailwind scanner.
   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 dependencies 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>()
-
-  // The resolved path given to `source(…)`. When not given this is `null`.
-  private basePath: string | null = null
-
-  public overwriteCandidates: string[] | null = null
+  // List of all build dependencies (e.g. imported  stylesheets or plugins) and
+  // their last modification timestamp
+  private buildDependencies = new Map<string, number>()
 
   constructor(
     private id: string,
-    private getSharedCandidates: () => Map<string, Set<string>>,
     private base: string,
 
     private customCssResolver: (id: string, base: string) => Promise<string | false | undefined>,
@@ -429,38 +220,43 @@ class Root {
     addWatchFile: (file: string) => void,
     I: Instrumentation,
   ): Promise<string | false> {
-    this.lastContent = content
-
+    let requiresBuildPromise = this.requiresBuild()
     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)])
+    if (!this.compiler || !this.scanner || (await requiresBuildPromise)) {
+      clearRequireCache(Array.from(this.buildDependencies.keys()))
+      this.buildDependencies.clear()
+
+      this.addBuildDependency(idToPath(inputPath))
 
       DEBUG && I.start('Setup compiler')
+      let addBuildDependenciesPromises: Promise<void>[] = []
       this.compiler = await compile(content, {
         base: inputBase,
         shouldRewriteUrls: true,
         onDependency: (path) => {
           addWatchFile(path)
-          this.dependencies.add(path)
+          addBuildDependenciesPromises.push(this.addBuildDependency(path))
         },
 
         customCssResolver: this.customCssResolver,
         customJsResolver: this.customJsResolver,
       })
+      await Promise.all(addBuildDependenciesPromises)
       DEBUG && I.end('Setup compiler')
 
+      DEBUG && I.start('Setup scanner')
+
       let sources = (() => {
         // Disable auto source detection
         if (this.compiler.root === 'none') {
           return []
         }
 
-        // No root specified, use the module graph
+        // No root specified, auto-detect based on the `**/*` pattern
         if (this.compiler.root === null) {
-          return []
+          return [{ base: this.base, pattern: '**/*' }]
         }
 
         // Use the specified root
@@ -468,6 +264,7 @@ class Root {
       })().concat(this.compiler.globs)
 
       this.scanner = new Scanner({ sources })
+      DEBUG && I.end('Setup scanner')
     }
 
     if (
@@ -479,7 +276,7 @@ class Root {
       return false
     }
 
-    if (!this.overwriteCandidates || this.compiler.features & Features.Utilities) {
+    if (this.compiler.features & Features.Utilities) {
       // 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).
@@ -525,59 +322,29 @@ class Root {
               `The path given to \`source(…)\` must be a directory but got \`source(${basePath})\` instead.`,
             )
           }
-
-          this.basePath = basePath
-        } else if (root === null) {
-          this.basePath = null
         }
       }
     }
 
-    this.requiresRebuild = true
-
     DEBUG && I.start('Build CSS')
-    let result = this.compiler.build(
-      this.overwriteCandidates
-        ? this.overwriteCandidates
-        : [...this.sharedCandidates(), ...this.candidates],
-    )
+    let result = this.compiler.build([...this.candidates])
     DEBUG && I.end('Build CSS')
 
     return result
   }
 
-  private sharedCandidates(): Set<string> {
-    if (!this.compiler) return new Set()
-    if (this.compiler.root === 'none') return new Set()
-
-    const HAS_DRIVE_LETTER = /^[A-Z]:/
-
-    let shouldIncludeCandidatesFrom = (id: string) => {
-      if (this.basePath === null) return true
-
-      if (id.startsWith(this.basePath)) return true
-
-      // This is a windows absolute path that doesn't match so return false
-      if (HAS_DRIVE_LETTER.test(id)) return false
-
-      // We've got a path that's not absolute and not on Windows
-      // TODO: this is probably a virtual module -- not sure if we need to scan it
-      if (!id.startsWith('/')) return true
-
-      // This is an absolute path on POSIX and it does not match
-      return false
-    }
-
-    let shared = new Set<string>()
-
-    for (let [id, candidates] of this.getSharedCandidates()) {
-      if (!shouldIncludeCandidatesFrom(id)) continue
+  private async addBuildDependency(path: string) {
+    let stat = await fs.stat(path)
+    this.buildDependencies.set(path, stat.mtimeMs)
+  }
 
-      for (let candidate of candidates) {
-        shared.add(candidate)
+  private async requiresBuild(): Promise<boolean> {
+    for (let [path, mtime] of this.buildDependencies) {
+      let stat = await fs.stat(path)
+      if (stat.mtimeMs > mtime) {
+        return true
       }
     }
-
-    return shared
+    return false
   }
 }


Test patch:
diff --git a/integrations/vite/astro.test.ts b/integrations/vite/astro.test.ts
--- a/integrations/vite/astro.test.ts
+++ b/integrations/vite/astro.test.ts
@@ -1,4 +1,4 @@
-import { candidate, fetchStyles, html, json, retryAssertion, test, ts } from '../utils'
+import { candidate, fetchStyles, html, js, json, retryAssertion, test, ts } from '../utils'
 
 test(
   'dev mode',
@@ -19,11 +19,7 @@ test(
         import { defineConfig } from 'astro/config'
 
         // https://astro.build/config
-        export default defineConfig({
-          vite: {
-            plugins: [tailwindcss()],
-          },
-        })
+        export default defineConfig({ vite: { plugins: [tailwindcss()] } })
       `,
       'src/pages/index.astro': html`
        <div class="underline">Hello, world!</div>
@@ -70,3 +66,58 @@ test(
     })
   },
 )
+
+test(
+  'build mode',
+  {
+    fs: {
+      'package.json': json`
+        {
+          "type": "module",
+          "dependencies": {
+            "astro": "^4.15.2",
+            "react": "^19",
+            "react-dom": "^19",
+            "@astrojs/react": "^4",
+            "@tailwindcss/vite": "workspace:^",
+            "tailwindcss": "workspace:^"
+          }
+        }
+      `,
+      'astro.config.mjs': ts`
+        import tailwindcss from '@tailwindcss/vite'
+        import react from '@astrojs/react'
+        import { defineConfig } from 'astro/config'
+
+        // https://astro.build/config
+        export default defineConfig({ vite: { plugins: [tailwindcss()] }, integrations: [react()] })
+      `,
+      'src/pages/index.astro': html`
+        ---
+        import ClientOnly from './client-only';
+        ---
+
+        <div class="underline">Hello, world!</div>
+
+        <ClientOnly client:only="react" />
+
+        <style is:global>
+          @import 'tailwindcss';
+        </style>
+      `,
+      'src/pages/client-only.jsx': js`
+        export default function ClientOnly() {
+          return <div className="overline">Hello, world!</div>
+        }
+      `,
+    },
+  },
+  async ({ fs, exec, expect }) => {
+    await exec('pnpm astro build')
+
+    let files = await fs.glob('dist/**/*.css')
+    expect(files).toHaveLength(1)
+
+    await fs.expectFileToContain(files[0][0], [candidate`underline`, candidate`overline`])
+  },
+)
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
@@ -174,21 +174,10 @@ describe.each(['postcss', 'lightningcss'])('%s', (transformer) => {
         return Boolean(url)
       })
 
-      // Candidates are resolved lazily, so the first visit of index.html
-      // will only have candidates from this file.
       await retryAssertion(async () => {
         let styles = await fetchStyles(url, '/index.html')
         expect(styles).toContain(candidate`underline`)
         expect(styles).toContain(candidate`flex`)
-        expect(styles).not.toContain(candidate`font-bold`)
-      })
-
-      // Going to about.html will extend the candidate list to include
-      // candidates from about.html.
-      await retryAssertion(async () => {
-        let styles = await fetchStyles(url, '/about.html')
-        expect(styles).toContain(candidate`underline`)
-        expect(styles).toContain(candidate`flex`)
         expect(styles).toContain(candidate`font-bold`)
       })
 
@@ -696,7 +685,7 @@ describe.each(['postcss', 'lightningcss'])('%s', (transformer) => {
 })
 
 test(
-  `demote Tailwind roots to regular CSS files and back to Tailwind roots while restoring all candidates`,
+  `demote Tailwind roots to regular CSS files and back to Tailwind roots`,
   {
     fs: {
       'package.json': json`
@@ -750,19 +739,9 @@ test(
       return Boolean(url)
     })
 
-    // Candidates are resolved lazily, so the first visit of index.html
-    // will only have candidates from this file.
     await retryAssertion(async () => {
       let styles = await fetchStyles(url, '/index.html')
       expect(styles).toContain(candidate`underline`)
-      expect(styles).not.toContain(candidate`font-bold`)
-    })
-
-    // Going to about.html will extend the candidate list to include
-    // candidates from about.html.
-    await retryAssertion(async () => {
-      let styles = await fetchStyles(url, '/about.html')
-      expect(styles).toContain(candidate`underline`)
       expect(styles).toContain(candidate`font-bold`)
     })
 
diff --git a/integrations/vite/react-router.test.ts b/integrations/vite/react-router.test.ts
new file mode 100644
--- /dev/null
+++ b/integrations/vite/react-router.test.ts
@@ -0,0 +1,178 @@
+import { candidate, css, fetchStyles, json, retryAssertion, test, ts, txt } from '../utils'
+
+const WORKSPACE = {
+  'package.json': json`
+    {
+      "type": "module",
+      "dependencies": {
+        "@react-router/dev": "^7",
+        "@react-router/node": "^7",
+        "@react-router/serve": "^7",
+        "@tailwindcss/vite": "workspace:^",
+        "@types/node": "^20",
+        "@types/react-dom": "^19",
+        "@types/react": "^19",
+        "isbot": "^5",
+        "react-dom": "^19",
+        "react-router": "^7",
+        "react": "^19",
+        "tailwindcss": "workspace:^",
+        "vite": "^5"
+      }
+    }
+  `,
+  'react-router.config.ts': ts`
+    import type { Config } from '@react-router/dev/config'
+    export default { ssr: true } satisfies Config
+  `,
+  'vite.config.ts': ts`
+    import { defineConfig } from 'vite'
+    import { reactRouter } from '@react-router/dev/vite'
+    import tailwindcss from '@tailwindcss/vite'
+
+    export default defineConfig({
+      plugins: [tailwindcss(), reactRouter()],
+    })
+  `,
+  'app/routes/home.tsx': ts`
+    export default function Home() {
+      return <h1 className="font-bold">Welcome to React Router</h1>
+    }
+  `,
+  'app/app.css': css`@import 'tailwindcss';`,
+  'app/routes.ts': ts`
+    import { type RouteConfig, index } from '@react-router/dev/routes'
+    export default [index('routes/home.tsx')] satisfies RouteConfig
+  `,
+  'app/root.tsx': ts`
+    import { Links, Meta, Outlet, Scripts, ScrollRestoration } from 'react-router'
+    import './app.css'
+    export function Layout({ children }: { children: React.ReactNode }) {
+      return (
+        <html lang="en">
+          <head>
+            <meta charSet="utf-8" />
+            <meta name="viewport" content="width=device-width, initial-scale=1" />
+            <Meta />
+            <Links />
+          </head>
+          <body>
+            {children}
+            <ScrollRestoration />
+            <Scripts />
+          </body>
+        </html>
+      )
+    }
+
+    export default function App() {
+      return <Outlet />
+    }
+  `,
+}
+
+test('dev mode', { fs: WORKSPACE }, async ({ fs, spawn, expect }) => {
+  let process = await spawn('pnpm react-router dev')
+
+  let url = ''
+  await process.onStdout((m) => {
+    let match = /Local:\s*(http.*)\//.exec(m)
+    if (match) url = match[1]
+    return Boolean(url)
+  })
+
+  await retryAssertion(async () => {
+    let css = await fetchStyles(url)
+    expect(css).toContain(candidate`font-bold`)
+  })
+
+  await retryAssertion(async () => {
+    await fs.write(
+      'app/routes/home.tsx',
+      ts`
+        export default function Home() {
+          return <h1 className="font-bold underline">Welcome to React Router</h1>
+        }
+      `,
+    )
+
+    let css = await fetchStyles(url)
+    expect(css).toContain(candidate`underline`)
+    expect(css).toContain(candidate`font-bold`)
+  })
+})
+
+test('build mode', { fs: WORKSPACE }, async ({ spawn, exec, expect }) => {
+  await exec('pnpm react-router build')
+  let process = await spawn('pnpm react-router-serve ./build/server/index.js')
+
+  let url = ''
+  await process.onStdout((m) => {
+    let match = /\[react-router-serve\]\s*(http.*)\ \/?/.exec(m)
+    if (match) url = match[1]
+    return url != ''
+  })
+
+  await retryAssertion(async () => {
+    let css = await fetchStyles(url)
+    expect(css).toContain(candidate`font-bold`)
+  })
+})
+
+test(
+  'build mode using ?url stylesheet imports should only build one stylesheet (requires `file-system` scanner)',
+  {
+    fs: {
+      ...WORKSPACE,
+      'app/root.tsx': ts`
+        import { Links, Meta, Outlet, Scripts, ScrollRestoration } from 'react-router'
+        import styles from './app.css?url'
+        export const links: Route.LinksFunction = () => [{ rel: 'stylesheet', href: styles }]
+        export function Layout({ children }: { children: React.ReactNode }) {
+          return (
+            <html lang="en">
+              <head>
+                <meta charSet="utf-8" />
+                <meta name="viewport" content="width=device-width, initial-scale=1" />
+                <Meta />
+                <Links />
+              </head>
+              <body class="dark">
+                {children}
+                <ScrollRestoration />
+                <Scripts />
+              </body>
+            </html>
+          )
+        }
+
+        export default function App() {
+          return <Outlet />
+        }
+      `,
+      'vite.config.ts': ts`
+        import { defineConfig } from 'vite'
+        import { reactRouter } from '@react-router/dev/vite'
+        import tailwindcss from '@tailwindcss/vite'
+
+        export default defineConfig({
+          plugins: [tailwindcss(), reactRouter()],
+        })
+      `,
+      '.gitignore': txt`
+        node_modules/
+        build/
+      `,
+    },
+  },
+  async ({ fs, exec, expect }) => {
+    await exec('pnpm react-router build')
+
+    let files = await fs.glob('build/client/assets/**/*.css')
+
+    expect(files).toHaveLength(1)
+    let [filename] = files[0]
+
+    await fs.expectFileToContain(filename, [candidate`font-bold`])
+  },
+)
diff --git a/integrations/vite/ssr.test.ts b/integrations/vite/ssr.test.ts
--- a/integrations/vite/ssr.test.ts
+++ b/integrations/vite/ssr.test.ts
@@ -1,5 +1,30 @@
 import { candidate, css, html, json, test, ts } from '../utils'
 
+const WORKSPACE = {
+  'index.html': html`
+    <body>
+      <div id="app"></div>
+      <script type="module" src="./src/index.ts"></script>
+    </body>
+  `,
+  'src/index.css': css`@import 'tailwindcss';`,
+  'src/index.ts': ts`
+    import './index.css'
+
+    document.querySelector('#app').innerHTML = \`
+      <div class="underline m-2">Hello, world!</div>
+    \`
+  `,
+  'server.ts': ts`
+    import css from './src/index.css?url'
+
+    document.querySelector('#app').innerHTML = \`
+      <link rel="stylesheet" href="\${css}">
+      <div class="overline m-3">Hello, world!</div>
+    \`
+  `,
+}
+
 test(
   'Vite 5',
   {
@@ -27,31 +52,9 @@ test(
             ssrEmitAssets: true,
           },
           plugins: [tailwindcss()],
-          ssr: { resolve: { conditions: [] } },
         })
       `,
-      'index.html': html`
-        <body>
-          <div id="app"></div>
-          <script type="module" src="./src/index.ts"></script>
-        </body>
-      `,
-      'src/index.css': css`@import 'tailwindcss';`,
-      'src/index.ts': ts`
-        import './index.css'
-
-        document.querySelector('#app').innerHTML = \`
-          <div class="underline m-2">Hello, world!</div>
-        \`
-      `,
-      'server.ts': ts`
-        import css from './src/index.css?url'
-
-        document.querySelector('#app').innerHTML = \`
-          <link rel="stylesheet" href="\${css}">
-          <div class="underline m-2">Hello, world!</div>
-        \`
-      `,
+      ...WORKSPACE,
     },
   },
   async ({ fs, exec, expect }) => {
@@ -62,9 +65,10 @@ test(
     let [filename] = files[0]
 
     await fs.expectFileToContain(filename, [
-      //
       candidate`underline`,
       candidate`m-2`,
+      candidate`overline`,
+      candidate`m-3`,
     ])
   },
 )
@@ -95,31 +99,9 @@ test(
             ssrEmitAssets: true,
           },
           plugins: [tailwindcss()],
-          ssr: { resolve: { conditions: [] } },
         })
       `,
-      'index.html': html`
-        <body>
-          <div id="app"></div>
-          <script type="module" src="./src/index.ts"></script>
-        </body>
-      `,
-      'src/index.css': css`@import 'tailwindcss';`,
-      'src/index.ts': ts`
-        import './index.css'
-
-        document.querySelector('#app').innerHTML = \`
-          <div class="underline m-2">Hello, world!</div>
-        \`
-      `,
-      'server.ts': ts`
-        import css from './src/index.css?url'
-
-        document.querySelector('#app').innerHTML = \`
-          <link rel="stylesheet" href="\${css}">
-          <div class="underline m-2">Hello, world!</div>
-        \`
-      `,
+      ...WORKSPACE,
     },
   },
   async ({ fs, exec, expect }) => {
@@ -130,9 +112,10 @@ test(
     let [filename] = files[0]
 
     await fs.expectFileToContain(filename, [
-      //
       candidate`underline`,
       candidate`m-2`,
+      candidate`overline`,
+      candidate`m-3`,
     ])
   },
 )