/
githubmirror
/
tailwindcss
Обзор
Документация
Войти
/
githubmirror
/
tailwindcss
Код
Запросы
0
Пакеты
0
Релизы
0
Аналитика
Безопасность
main
packages/@tailwindcss-vite/src/index.ts
763 строки
25 KB
Lazizbek Ergashev
Prevent `@tailwindcss/vite` crash under Vite's experimental `bundledDev` (#20379)
03 авг 2026, 16:10
Не верифицирован
03 авг 2026, 16:10
50daebd
Код
Авторство
О чём код?
import { compile, env, Features, Instrumentation, normalizePath, optimize, toSourceMap, } from '@tailwindcss/node' import { clearRequireCache } from '@tailwindcss/node/require-cache' import { Scanner } from '@tailwindcss/oxide' import { realpathSync } from 'node:fs' import fs from 'node:fs/promises' import path from 'node:path' import type { Environment, InternalResolveOptions, Plugin, ResolvedConfig, ViteDevServer, } from 'vite' import * as vite 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 JS_EXTENSIONS_RE = /^\.[cm]?[jt]sx?$/ export type PluginOptions = { /** * Optimize and minify the output CSS. */ optimize?: boolean | { minify?: boolean } } function createCustomResolver( resolvers: ((id: string, importer: string) => Promise<string | undefined>)[], filter = (_path: string) => true, ) { return async (id: string, base: string) => { // The resolver expects an `importer` file. We don't really know where the // current `id` was imported from, but Vite will essentially do a // `path.dirname(importer)` so it doesn't really matter. // // It does matter that this is a file, otherwise we would go up a directory, // which means that we would be resolving files from a parent folder first, // instead of the current folder we are in. let importer = path.resolve(base, '__placeholder__.ts') for (let resolver of resolvers) { let resolved = await resolver(id, importer) // If we didn't resolve, we don't have to bail immediately, but we can try // the next resolver if (!resolved) continue if (resolved === id) continue // Looks like a relative file, let's resolve it to an absolute path if (resolved[0] === '.') resolved = path.resolve(base, resolved) // Must adhere to additional filters (e.g.: must be a .css file) if (!filter(resolved)) continue // If it's not an absolute path, then we don't really know how to read // the file from disk. if (!path.isAbsolute(resolved)) continue return resolved } } } export default function tailwindcss(opts: PluginOptions = {}): Plugin[] { let servers: ViteDevServer[] = [] let config: ResolvedConfig | null = null let rootsByEnv = new DefaultMap<string, Map<string, Root>>((env: string) => new Map()) // File extensions that Vite (or one of its plugins) has been seen to process // as a module. Plugins don't get added or removed while the dev server is // running (changing the Vite config restarts the server), so once we've seen // evidence for a file type we don't need to scan the module graphs for it // again. let viteProcessedExtensions = new Set<string>() let isSSR = false let shouldOptimize = true let minify = true function createRoot(env: Environment | null, id: string) { type ResolveFn = (id: string, base: string) => Promise<string | false | undefined> let customCssResolver: ResolveFn let customJsResolver: ResolveFn if (!env) { // Older, pre-environment Vite API // TODO: Can we drop this?? let cssResolver = config!.createResolver({ ...config!.resolve, extensions: ['.css'], mainFields: ['style'], conditions: ['style', 'development|production'], tryIndex: false, preferRelative: true, }) let jsResolver = config!.createResolver(config!.resolve) customCssResolver = createCustomResolver( [ (id, importer) => cssResolver(id, importer, true, isSSR), (id, importer) => cssResolver(id, importer, false, isSSR), ], (path) => path.endsWith('.css'), ) customJsResolver = createCustomResolver( [ (id, importer) => jsResolver(id, importer, true, isSSR), (id, importer) => jsResolver(id, importer, false, isSSR), ], (path) => !path.endsWith('.css'), ) } else { type ResolveIdFn = ( environment: Environment, id: string, importer?: string, aliasOnly?: boolean, ) => Promise<string | undefined> // There are cases where Environment API is available, // but `createResolver` is still overridden (for example astro v5) // // Copied as-is from vite, because this function is not a part of public API // // TODO: Remove this function and pre-environment code when Vite < 7 is no longer supported function createBackCompatIdResolver( config: ResolvedConfig, options?: Partial<InternalResolveOptions>, ): ResolveIdFn { const compatResolve = config.createResolver(options) let resolve: ResolveIdFn return async (environment, id, importer, aliasOnly) => { if (environment.name === 'client' || environment.name === 'ssr') { return compatResolve(id, importer, aliasOnly, environment.name === 'ssr') } resolve ??= vite.createIdResolver(config, options) return resolve(environment, id, importer, aliasOnly) } } // Newer Vite versions let cssResolver = createBackCompatIdResolver(env.config, { ...env.config.resolve, extensions: ['.css'], mainFields: ['style'], conditions: ['style', 'development|production'], tryIndex: false, preferRelative: true, }) let jsResolver = createBackCompatIdResolver(env.config, env.config.resolve) customCssResolver = createCustomResolver( [ (id, importer) => cssResolver(env, id, importer, true), (id, importer) => cssResolver(env, id, importer, false), ], (path) => path.endsWith('.css'), ) customJsResolver = createCustomResolver( [ (id, importer) => jsResolver(env, id, importer, true), (id, importer) => jsResolver(env, id, importer, false), ], (path) => !path.endsWith('.css'), ) } return new Root( id, config!.root, // Currently, Vite only supports CSS source maps in development and they // are off by default. Check to see if we need them or not. config?.css.devSourcemap ?? false, customCssResolver, customJsResolver, ) } return [ { // Step 1: Scan source files for candidates name: '@tailwindcss/vite:scan', enforce: 'pre', configureServer(server) { servers.push(server) }, async configResolved(_config) { config = _config isSSR = config.build.ssr !== false && config.build.ssr !== undefined // By default we optimize CSS during the build phase but if the user // provides explicit options we'll use those instead if (opts.optimize !== undefined) { shouldOptimize = opts.optimize !== false } // Minification is also performed when optimizing as long as it's also // enabled in Vite minify = shouldOptimize && config.build.cssMinify !== false // But again, the user can override that choice explicitly if (typeof opts.optimize === 'object') { minify = opts.optimize.minify !== false } }, }, { // Step 2 (serve mode): Generate CSS name: '@tailwindcss/vite:generate:serve', apply: 'serve', enforce: 'pre', transform: { filter: { id: { exclude: [/\/\.vite\//, SPECIAL_QUERY_RE, COMMON_JS_PROXY_RE], include: [/\.css(?:\?.*)?$/, /&lang\.css/, INLINE_STYLE_ID_RE], }, }, async handler(src, id) { if (!isPotentialCssRootFile(id)) return using I = new Instrumentation() DEBUG && I.start('[@tailwindcss/vite] Generate CSS (serve)') let roots = rootsByEnv.get(this.environment?.name ?? 'default') let root = roots.get(id) if (!root) { root ??= createRoot(this.environment ?? null, id) roots.set(id, root) } let result = await root.generate(src, (file) => this.addWatchFile(file), I) if (!result) { roots.delete(id) return } DEBUG && I.end('[@tailwindcss/vite] Generate CSS (serve)') return result }, }, hotUpdate({ file, modules, timestamp, server }) { // Vite's experimental `bundledDev` mode invokes `hotUpdate` without a // `server`, so there are no sibling environments to inspect and no // server-level `hot`/`ws` channel to reload through. Bail out early // rather than dereferencing `undefined`. // // https://github.com/tailwindlabs/tailwindcss/issues/20378 if (!server) return // Ensure full-reloads are triggered for files that are being watched by // Tailwind but aren't part of the module graph (like PHP or HTML // files). If we don't do this, then changes to those files won't // trigger a reload at all since Vite doesn't know about them. { // It's a little bit confusing, because due to the `addWatchFile` // calls, it _is_ part of the module graph but nothing is really // handling those files. These modules typically have an id of // undefined and/or have a type of 'asset'. // // If we call `addWatchFile` on a file that is part of the actual // module graph, then we will see a module for it with a type of `js` // and a type of `asset`. We are only interested if _all_ of them are // missing an id and/or have a type of 'asset', which is a strong // signal that the changed file is not being handled by Vite or any of // the plugins. // // Note: in Vite v7.0.6 the modules here will have a type of `js`, not // 'asset'. But it will also have a `HARD_INVALIDATED` state and will // do a full page reload already. // // Empty modules can be skipped since it means it's not // `addWatchFile`d and thus irrelevant to Tailwind. let isExternalFile = modules.length > 0 && modules.every((mod) => mod.type === 'asset' || mod.id === undefined) if (!isExternalFile) return // Skip files that Vite (or one of its plugins) processes as a // module — in this environment (e.g. a lazily-loaded route that // hasn't been visited yet) or in another one (e.g. an SSR-only // module). Such a file can only affect the page through Vite's own // pipeline, so a full reload would only destroy client state. Any // changes to the generated CSS still go through the regular // `css-update` flow because the file is registered via // `addWatchFile`. // // If the file exists as a real module in another environment, then // that environment is responsible for it. E.g. an SSR framework // has its own server side hmr/reload mechanism when handling // server only modules. See https://v6.vite.dev/guide/migration.html // > Updates to an SSR-only module no longer triggers a full page reload in the client. ... for (let environment of Object.values(server.environments)) { if (environment.name === this.environment.name) continue let modules = environment.moduleGraph.getModulesByFile(file) if (modules) { for (let mod of modules) { if (mod.type !== 'asset') { return } } } } // Otherwise the file is not loaded as a module anywhere, so // determine whether its file _type_ would be processed by Vite // when requested by the browser (in which case the file just isn't // loaded yet, e.g. a lazily-loaded route that hasn't been visited). // Vite has no API to answer this without actually running the // plugin pipeline, so instead: // // Files Vite handles natively (the JS/TS and CSS families) are always // processed by Vite. This includes stylesheets that never show up as // their own module because a framework plugin compiles them into a // component (e.g. Angular), in which case that plugin owns their HMR. let extension = path.extname(file) if (JS_EXTENSIONS_RE.test(extension) || vite.isCSSRequest(file)) return // For any other file type (e.g. `.vue`, `.svelte`, or `.md` with an // SSG plugin), if a file with the same extension exists as a real // module in any environment's module graph, then a plugin evidently // handles this file type and the changed file just isn't loaded // (yet). if (extension !== '') { if (viteProcessedExtensions.has(extension)) return for (let environment of Object.values(server.environments)) { for (let mod of environment.moduleGraph.idToModuleMap.values()) { if (!mod.file?.endsWith(extension)) continue if (mod.type === 'asset') continue // Only count modules that the plugin pipeline actually // transformed. Vite also creates untransformed placeholder // nodes (e.g. for the file underlying a `?raw` import) that // are not evidence that a plugin handles this file type. if (mod.transformResult == null) continue // Similarly, ignore query imports (e.g. `./template.html?raw`, // or the `?html-proxy` modules Vite creates for inline // scripts): they pull a file's _contents_ into the graph // without a plugin processing the file type. A scanned // `.html` template must still trigger a full reload even if // some other `.html` file is imported with `?raw`. if (!mod.id || mod.id.includes('?')) continue viteProcessedExtensions.add(extension) return } } } for (let env of new Set([this.environment.name, 'client'])) { let roots = rootsByEnv.get(env) if (roots.size === 0) continue // If the file is not being watched by any of the roots, then we can // skip the reload since it's not relevant to Tailwind CSS. if (!isScannedFile(file, modules, roots)) { continue } // https://vite.dev/changes/hotupdate-hook#migration-guide let invalidatedModules = new Set<vite.EnvironmentModuleNode>() for (let mod of modules) { this.environment.moduleGraph.invalidateModule( mod, invalidatedModules, timestamp, true, ) } if (env === this.environment.name) { this.environment.hot.send({ type: 'full-reload' }) } else if (server.hot.send) { server.hot.send({ type: 'full-reload' }) } else if (server.ws.send) { server.ws.send({ type: 'full-reload' }) } return [] } } }, }, { // Step 2 (full build): Generate CSS name: '@tailwindcss/vite:generate:build', apply: 'build', enforce: 'pre', transform: { filter: { id: { exclude: [/\/\.vite\//, SPECIAL_QUERY_RE, COMMON_JS_PROXY_RE], include: [/\.css(?:\?.*)?$/, /&lang\.css/, INLINE_STYLE_ID_RE], }, }, async handler(src, id) { if (!isPotentialCssRootFile(id)) return using I = new Instrumentation() DEBUG && I.start('[@tailwindcss/vite] Generate CSS (build)') let roots = rootsByEnv.get(this.environment?.name ?? 'default') let root = roots.get(id) if (!root) { root ??= createRoot(this.environment ?? null, id) roots.set(id, root) } let result = await root.generate(src, (file) => this.addWatchFile(file), I) if (!result) { roots.delete(id) return } DEBUG && I.end('[@tailwindcss/vite] Generate CSS (build)') if (shouldOptimize) { DEBUG && I.start('[@tailwindcss/vite] Optimize CSS') result = optimize(result.code, { minify, map: result.map, }) DEBUG && I.end('[@tailwindcss/vite] Optimize CSS') } return result }, }, }, ] satisfies Plugin[] } function getExtension(id: string) { let [filename] = id.split('?', 2) return path.extname(filename).slice(1) } function isPotentialCssRootFile(id: string) { if (id.includes('/.vite/')) return false // Don't intercept special static asset resources if (SPECIAL_QUERY_RE.test(id)) return false if (COMMON_JS_PROXY_RE.test(id)) return false let extension = getExtension(id) let isCssFile = extension === 'css' || id.includes('&lang.css') || id.match(INLINE_STYLE_ID_RE) return isCssFile } 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 { // 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>> // 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 build dependencies (e.g. imported stylesheets or plugins) and // their last modification timestamp. If no mtime can be found, we need to // assume the file has always changed. private buildDependencies = new Map<string, number | null>() constructor( private id: string, private base: string, private enableSourceMaps: boolean, private customCssResolver: (id: string, base: string) => Promise<string | false | undefined>, private customJsResolver: (id: string, base: string) => Promise<string | false | undefined>, ) {} get scannedFiles() { return this.scanner?.files ?? [] } // 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, I: Instrumentation, ): Promise< | { code: string map: string | undefined } | false > { let inputPath = idToPath(this.id) function addWatchFile(file: string) { // Don't watch the input file since it's already a dependency and causes // issues with some setups (e.g. Qwik). if (file === inputPath) { return } // Scanning `.svg` file containing a `#` or `?` in the path will // crash Vite. We work around this for now by ignoring updates to them. // // https://github.com/tailwindlabs/tailwindcss/issues/16877 if (/[#?].*\.svg$/.test(file)) { return } _addWatchFile(file) } let requiresBuildPromise = this.requiresBuild() let inputBase = path.dirname(path.resolve(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, { from: this.enableSourceMaps ? this.id : undefined, base: inputBase, shouldRewriteUrls: true, onDependency: (path) => { addWatchFile(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, auto-detect based on the `**/*` pattern if (this.compiler.root === null) { return [{ base: this.base, pattern: '**/*', negated: false }] } // Use the specified root return [{ ...this.compiler.root, negated: false }] })().concat(this.compiler.sources) this.scanner = new Scanner({ sources }) DEBUG && I.end('Setup scanner') } else { for (let buildDependency of this.buildDependencies.keys()) { addWatchFile(buildDependency) } } if ( !( this.compiler.features & (Features.AtApply | Features.JsPluginCompat | Features.ThemeFunction | Features.Utilities | Features.Variants) ) ) { return false } 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). DEBUG && I.start('Scan for candidates') for (let candidate of this.scanner.scan()) { this.candidates.add(candidate) } DEBUG && I.end('Scan for candidates') } if (this.compiler.features & Features.Utilities) { DEBUG && I.start('Register dependency messages') // 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)) let root = this.compiler.root if (root !== 'none' && root !== null) { let basePath = normalizePath(path.resolve(root.base, root.pattern)) let isDir = await fs.stat(basePath).then( (stats) => stats.isDirectory(), () => false, ) if (!isDir) { throw new Error( `The path given to \`source(…)\` must be a directory but got \`source(${basePath})\` instead.`, ) } } } DEBUG && I.end('Register dependency messages') } DEBUG && I.start('Build CSS') let code = this.compiler.build([...this.candidates]) DEBUG && I.end('Build CSS') DEBUG && I.start('Build Source Map') let map = this.enableSourceMaps ? toSourceMap(this.compiler.buildSourceMap()).raw : undefined DEBUG && I.end('Build Source Map') return { code, map, } } private async addBuildDependency(path: string) { let mtime: number | null = null try { mtime = (await fs.stat(path)).mtimeMs } catch {} this.buildDependencies.set(path, mtime) } private async requiresBuild(): Promise<boolean> { for (let [path, mtime] of this.buildDependencies) { if (mtime === null) return true try { let stat = await fs.stat(path) if (stat.mtimeMs > mtime) { return true } } catch { return true } } return false } } function isScannedFile( file: string, modules: vite.EnvironmentModuleNode[], roots: Map<string, Root>, ) { let seen = new Set() let q = [...modules] let checks = { file, get realpath() { try { let realpath = realpathSync(file) Object.defineProperty(checks, 'realpath', { value: realpath }) return realpath } catch { return null } }, } while (q.length > 0) { let module = q.shift()! if (seen.has(module)) continue seen.add(module) if (module.id) { let root = roots.get(module.id) if (root) { // If the file is part of the scanned files for this root, then we know // for sure that it's being watched by any of the Tailwind CSS roots. It // doesn't matter which root it is since it's only used to know whether // we should trigger a full reload or not. if ( root.scannedFiles.includes(checks.file) || (checks.realpath && root.scannedFiles.includes(checks.realpath)) ) { return true } } } // Keep walking up the tree until we find a root. for (let importer of module.importers) { q.push(importer) } } return false }