## Summary `@tailwindcss/postcss` chooses between an incremental and a full rebuild by comparing the mtimes of the entry file and its resolved `@import`/`@config`/`@plugin` graph. It never looks at the input CSS itself, so when that CSS is produced by an upstream tool (e.g. Sass) and passed to the plugin via `process()`, it can change while the `from` file's mtime stays the same. The plugin then re-emits its previously cached output and silently drops the change. This stores the input CSS per cache entry and takes the existing full rebuild path when it differs from the previous compile, mirroring the fix the CLI watcher already has for changed input files. Fixes #20307 ## Test plan - Added a regression test in `packages/@tailwindcss-postcss/src/index.test.ts` that compiles two different inputs for the same on-disk `from` file (unchanged mtime) and asserts the second compile reflects the new CSS. - Confirmed it fails on `main` (the second compile returns the stale first output) and passes with the fix. - Ran the `@tailwindcss/postcss` package tests (all green) and checked formatting with Prettier. --------- Co-authored-by: Robin Malfait <malfait.robin@gmail.com>
377 lines
14 KiB
TypeScript
377 lines
14 KiB
TypeScript
import QuickLRU from '@alloc/quick-lru'
|
|
import {
|
|
compileAst,
|
|
env,
|
|
Features,
|
|
Instrumentation,
|
|
optimize as optimizeCss,
|
|
Polyfills,
|
|
} from '@tailwindcss/node'
|
|
import { clearRequireCache } from '@tailwindcss/node/require-cache'
|
|
import { Scanner } from '@tailwindcss/oxide'
|
|
import fs from 'node:fs'
|
|
import path, { relative } from 'node:path'
|
|
import type { AcceptedPlugin, PluginCreator, Postcss, Root } from 'postcss'
|
|
import { toCss, type AstNode } from '../../tailwindcss/src/ast'
|
|
import { cssAstToPostCssAst, postCssAstToCssAst } from './ast'
|
|
import fixRelativePathsPlugin from './postcss-fix-relative-paths'
|
|
|
|
const DEBUG = env.DEBUG
|
|
|
|
interface CacheEntry {
|
|
mtimes: Map<string, number>
|
|
inputCss: string
|
|
compiler: null | ReturnType<typeof compileAst>
|
|
scanner: null | Scanner
|
|
tailwindCssAst: AstNode[]
|
|
cachedPostCssAst: Root
|
|
optimizedPostCssAst: Root
|
|
fullRebuildPaths: string[]
|
|
}
|
|
const cache = new QuickLRU<string, CacheEntry>({ maxSize: 50 })
|
|
|
|
function getContextFromCache(postcss: Postcss, inputFile: string, opts: PluginOptions): CacheEntry {
|
|
let key = `${inputFile}:${opts.base ?? ''}:${JSON.stringify(opts.optimize)}`
|
|
if (cache.has(key)) return cache.get(key)!
|
|
let entry = {
|
|
mtimes: new Map<string, number>(),
|
|
inputCss: '',
|
|
compiler: null,
|
|
scanner: null,
|
|
|
|
tailwindCssAst: [],
|
|
cachedPostCssAst: postcss.root(),
|
|
optimizedPostCssAst: postcss.root(),
|
|
|
|
fullRebuildPaths: [] as string[],
|
|
}
|
|
cache.set(key, entry)
|
|
return entry
|
|
}
|
|
|
|
export type PluginOptions = {
|
|
/**
|
|
* The base directory to scan for class candidates.
|
|
*
|
|
* Defaults to the current working directory.
|
|
*/
|
|
base?: string
|
|
|
|
/**
|
|
* Optimize and minify the output CSS.
|
|
*/
|
|
optimize?: boolean | { minify?: boolean }
|
|
|
|
/**
|
|
* Enable or disable asset URL rewriting.
|
|
*
|
|
* Defaults to `true`.
|
|
*/
|
|
transformAssetUrls?: boolean
|
|
}
|
|
|
|
function tailwindcss(opts: PluginOptions = {}): AcceptedPlugin {
|
|
let base = opts.base ?? process.cwd()
|
|
let optimize = opts.optimize ?? process.env.NODE_ENV === 'production'
|
|
let shouldRewriteUrls = opts.transformAssetUrls ?? true
|
|
|
|
return {
|
|
postcssPlugin: '@tailwindcss/postcss',
|
|
plugins: [
|
|
// We need to handle the case where `postcss-import` might have run before
|
|
// the Tailwind CSS plugin is run. In this case, we need to manually fix
|
|
// relative paths before processing it in core.
|
|
fixRelativePathsPlugin(),
|
|
|
|
{
|
|
postcssPlugin: 'tailwindcss',
|
|
async Once(root, { result, postcss }) {
|
|
using I = new Instrumentation()
|
|
|
|
let inputFile = result.opts.from ?? ''
|
|
let isCSSModuleFile = inputFile.endsWith('.module.css')
|
|
|
|
DEBUG && I.start(`[@tailwindcss/postcss] ${relative(base, inputFile)}`)
|
|
|
|
// Bail out early if this is guaranteed to be a non-Tailwind CSS file.
|
|
{
|
|
DEBUG && I.start('Quick bail check')
|
|
let canBail = true
|
|
root.walkAtRules((node) => {
|
|
if (
|
|
node.name === 'import' ||
|
|
node.name === 'reference' ||
|
|
node.name === 'theme' ||
|
|
node.name === 'variant' ||
|
|
node.name === 'config' ||
|
|
node.name === 'plugin' ||
|
|
node.name === 'apply' ||
|
|
node.name === 'tailwind'
|
|
) {
|
|
canBail = false
|
|
return false
|
|
}
|
|
})
|
|
if (canBail) return
|
|
DEBUG && I.end('Quick bail check')
|
|
}
|
|
|
|
let context = getContextFromCache(postcss, inputFile, opts)
|
|
let inputBasePath = inputFile ? path.dirname(path.resolve(inputFile)) : base
|
|
|
|
// Whether this is the first build or not, if it is, then we can
|
|
// optimize the build by not creating the compiler until we need it.
|
|
let isInitialBuild = context.compiler === null
|
|
|
|
async function createCompiler() {
|
|
DEBUG && I.start('Setup compiler')
|
|
if (context.fullRebuildPaths.length > 0 && !isInitialBuild) {
|
|
clearRequireCache(context.fullRebuildPaths)
|
|
}
|
|
|
|
context.fullRebuildPaths = []
|
|
|
|
DEBUG && I.start('PostCSS AST -> Tailwind CSS AST')
|
|
let ast = postCssAstToCssAst(root)
|
|
DEBUG && I.end('PostCSS AST -> Tailwind CSS AST')
|
|
|
|
DEBUG && I.start('Create compiler')
|
|
let compiler = await compileAst(ast, {
|
|
from: result.opts.from,
|
|
base: inputBasePath,
|
|
shouldRewriteUrls,
|
|
onDependency: (path) => context.fullRebuildPaths.push(path),
|
|
// In CSS Module files, we have to disable the `@property` polyfill since these will
|
|
// emit global `*` rules which are considered to be non-pure and will cause builds
|
|
// to fail.
|
|
polyfills: isCSSModuleFile ? Polyfills.All ^ Polyfills.AtProperty : Polyfills.All,
|
|
})
|
|
DEBUG && I.end('Create compiler')
|
|
|
|
DEBUG && I.end('Setup compiler')
|
|
return compiler
|
|
}
|
|
|
|
try {
|
|
// Setup the compiler if it doesn't exist yet. This way we can
|
|
// guarantee a `build()` function is available.
|
|
context.compiler ??= createCompiler()
|
|
|
|
if ((await context.compiler).features === Features.None) {
|
|
return
|
|
}
|
|
|
|
let rebuildStrategy: 'full' | 'incremental' = 'incremental'
|
|
|
|
// Track file modification times to CSS files
|
|
DEBUG && I.start('Register full rebuild paths')
|
|
{
|
|
for (let file of context.fullRebuildPaths) {
|
|
result.messages.push({
|
|
type: 'dependency',
|
|
plugin: '@tailwindcss/postcss',
|
|
file: path.resolve(file),
|
|
parent: result.opts.from,
|
|
})
|
|
}
|
|
|
|
let files = result.messages.flatMap((message) => {
|
|
if (message.type !== 'dependency') return []
|
|
return message.file
|
|
})
|
|
files.push(inputFile)
|
|
|
|
for (let file of files) {
|
|
let changedTime = fs.statSync(file, { throwIfNoEntry: false })?.mtimeMs ?? null
|
|
if (changedTime === null) {
|
|
if (file === inputFile) {
|
|
rebuildStrategy = 'full'
|
|
}
|
|
continue
|
|
}
|
|
|
|
let prevTime = context.mtimes.get(file)
|
|
if (prevTime === changedTime) continue
|
|
|
|
rebuildStrategy = 'full'
|
|
context.mtimes.set(file, changedTime)
|
|
}
|
|
|
|
// The mtimes above only track files on disk. When the input CSS is
|
|
// generated by another tool (e.g. Sass) and passed to `process()`,
|
|
// it can change without any tracked file's mtime changing, so also
|
|
// rebuild when the input CSS itself differs from the last build.
|
|
let inputCss = root.source?.input.css ?? root.toString()
|
|
if (context.inputCss !== inputCss) {
|
|
rebuildStrategy = 'full'
|
|
context.inputCss = inputCss
|
|
}
|
|
}
|
|
DEBUG && I.end('Register full rebuild paths')
|
|
|
|
if (
|
|
rebuildStrategy === 'full' &&
|
|
// We can reuse the compiler if it was created during the
|
|
// initial build. If it wasn't, we need to create a new one.
|
|
!isInitialBuild
|
|
) {
|
|
context.compiler = createCompiler()
|
|
}
|
|
|
|
let compiler = await context.compiler
|
|
|
|
if (context.scanner === null || rebuildStrategy === 'full') {
|
|
DEBUG && I.start('Setup scanner')
|
|
let sources = (() => {
|
|
// Disable auto source detection
|
|
if (compiler.root === 'none') {
|
|
return []
|
|
}
|
|
|
|
// No root specified, use the base directory
|
|
if (compiler.root === null) {
|
|
return [{ base, pattern: '**/*', negated: false }]
|
|
}
|
|
|
|
// Use the specified root
|
|
return [{ ...compiler.root, negated: false }]
|
|
})().concat(compiler.sources)
|
|
|
|
// Look for candidates used to generate the CSS
|
|
context.scanner = new Scanner({ sources })
|
|
DEBUG && I.end('Setup scanner')
|
|
}
|
|
|
|
DEBUG && I.start('Scan for candidates')
|
|
let candidates = compiler.features & Features.Utilities ? context.scanner.scan() : []
|
|
DEBUG && I.end('Scan for candidates')
|
|
|
|
if (compiler.features & Features.Utilities) {
|
|
DEBUG && I.start('Register dependency messages')
|
|
// Add all found files as direct dependencies
|
|
// Note: With Turbopack, the input file might not be a resolved path
|
|
let resolvedInputFile = path.resolve(base, inputFile)
|
|
for (let file of context.scanner.files) {
|
|
let absolutePath = path.resolve(file)
|
|
// The CSS file cannot be a dependency of itself
|
|
if (absolutePath === resolvedInputFile) {
|
|
continue
|
|
}
|
|
result.messages.push({
|
|
type: 'dependency',
|
|
plugin: '@tailwindcss/postcss',
|
|
file: absolutePath,
|
|
parent: result.opts.from,
|
|
})
|
|
}
|
|
|
|
// Register dependencies so changes in `base` cause a rebuild while
|
|
// giving tools like Vite or Parcel a glob that can be used to limit
|
|
// the files that cause a rebuild to only those that match it.
|
|
for (let { base: globBase, pattern } of context.scanner.globs) {
|
|
// Avoid adding a dependency on the base directory itself, since it
|
|
// causes Next.js to start an endless recursion if the `distDir` is
|
|
// configured to anything other than the default `.next` dir.
|
|
if (pattern === '*' && base === globBase) {
|
|
continue
|
|
}
|
|
|
|
if (pattern === '') {
|
|
result.messages.push({
|
|
type: 'dependency',
|
|
plugin: '@tailwindcss/postcss',
|
|
file: path.resolve(globBase),
|
|
parent: result.opts.from,
|
|
})
|
|
} else {
|
|
result.messages.push({
|
|
type: 'dir-dependency',
|
|
plugin: '@tailwindcss/postcss',
|
|
dir: path.resolve(globBase),
|
|
glob: pattern,
|
|
parent: result.opts.from,
|
|
})
|
|
}
|
|
}
|
|
DEBUG && I.end('Register dependency messages')
|
|
}
|
|
|
|
DEBUG && I.start('Build utilities')
|
|
let tailwindCssAst = compiler.build(candidates)
|
|
DEBUG && I.end('Build utilities')
|
|
|
|
if (context.tailwindCssAst !== tailwindCssAst) {
|
|
if (optimize) {
|
|
DEBUG && I.start('Optimization')
|
|
|
|
DEBUG && I.start('AST -> CSS')
|
|
let css = toCss(tailwindCssAst)
|
|
DEBUG && I.end('AST -> CSS')
|
|
|
|
DEBUG && I.start('Lightning CSS')
|
|
let optimized = optimizeCss(css, {
|
|
minify: typeof optimize === 'object' ? optimize.minify : true,
|
|
})
|
|
DEBUG && I.end('Lightning CSS')
|
|
|
|
DEBUG && I.start('CSS -> PostCSS AST')
|
|
context.optimizedPostCssAst = postcss.parse(optimized.code, result.opts)
|
|
DEBUG && I.end('CSS -> PostCSS AST')
|
|
|
|
DEBUG && I.end('Optimization')
|
|
} else {
|
|
// Convert our AST to a PostCSS AST
|
|
DEBUG && I.start('Transform Tailwind CSS AST into PostCSS AST')
|
|
context.cachedPostCssAst = cssAstToPostCssAst(postcss, tailwindCssAst, root.source)
|
|
DEBUG && I.end('Transform Tailwind CSS AST into PostCSS AST')
|
|
}
|
|
}
|
|
|
|
context.tailwindCssAst = tailwindCssAst
|
|
|
|
DEBUG && I.start('Update PostCSS AST')
|
|
root.removeAll()
|
|
root.append(
|
|
optimize
|
|
? context.optimizedPostCssAst.clone().nodes
|
|
: context.cachedPostCssAst.clone().nodes,
|
|
)
|
|
|
|
// Trick PostCSS into thinking the indent is 2 spaces, so it uses that
|
|
// as the default instead of 4.
|
|
root.raws.indent = ' '
|
|
DEBUG && I.end('Update PostCSS AST')
|
|
|
|
DEBUG && I.end(`[@tailwindcss/postcss] ${relative(base, inputFile)}`)
|
|
} catch (error) {
|
|
// An error requires a full rebuild to fix
|
|
context.compiler = null
|
|
|
|
// Ensure all dependencies we have collected thus far are included so that the rebuild
|
|
// is correctly triggered
|
|
for (let file of context.fullRebuildPaths) {
|
|
result.messages.push({
|
|
type: 'dependency',
|
|
plugin: '@tailwindcss/postcss',
|
|
file: path.resolve(file),
|
|
parent: result.opts.from,
|
|
})
|
|
}
|
|
|
|
// We found that throwing the error will cause PostCSS to no longer watch for changes
|
|
// in some situations so we instead log the error and continue with an empty stylesheet.
|
|
console.error(error)
|
|
|
|
if (error && typeof error === 'object' && 'message' in error) {
|
|
throw root.error(`${error.message}`)
|
|
}
|
|
|
|
throw root.error(`${error}`)
|
|
}
|
|
},
|
|
},
|
|
],
|
|
}
|
|
}
|
|
|
|
export default Object.assign(tailwindcss, { postcss: true }) as PluginCreator<PluginOptions>
|