- \`
- `,
+ ...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`,
])
},
)
diff --git a/packages/@tailwindcss-vite/src/index.ts b/packages/@tailwindcss-vite/src/index.ts
index ce61fdf9b64c..8e3d2348ea16 100644
--- 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>(() => new Set())
- let moduleGraphScanner = new Scanner({})
-
let roots: DefaultMap = 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 extends Map {
}
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>
- 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 = new Set()
- // 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()
-
- // 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()
constructor(
private id: string,
- private getSharedCandidates: () => Map>,
private base: string,
private customCssResolver: (id: string, base: string) => Promise,
@@ -429,38 +220,43 @@ class Root {
addWatchFile: (file: string) => void,
I: Instrumentation,
): Promise {
- 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[] = []
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 {
- 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()
-
- 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 {
+ for (let [path, mtime] of this.buildDependencies) {
+ let stat = await fs.stat(path)
+ if (stat.mtimeMs > mtime) {
+ return true
}
}
-
- return shared
+ return false
}
}