tailwindcss/packages/@tailwindcss-webpack/src/index.ts
Qingyu Wang 17d324f896
Fix webpack loader cache key for resource queries (#19723)
<!--

👋 Hey, thanks for your interest in contributing to Tailwind!

**Please ask first before starting work on any significant new
features.**

It's never a fun experience to have your pull request declined after
investing a lot of time and effort into a new feature. To avoid this
from happening, we request that contributors create a discussion to
first discuss any significant new features.

For more info, check out the contributing guide:


https://github.com/tailwindlabs/tailwindcss/blob/main/.github/CONTRIBUTING.md

-->

## Summary

<!--

Provide a summary of the issue and the changes you're making. How does
your change solve the problem?

-->

`@tailwindcss/webpack` currently uses `this.resourcePath` as the cache
key, which ignores the resource query. When the same CSS file is
imported multiple times with different `resourceQuery` values, all of
those imports share a single `CacheEntry`. That means the utilities
discovered for one entry can leak into the CSS output for another entry.

This PR changes the cache key to use `this.resource` (path + query)
instead, while still using `this.resourcePath` for all filesystem work.

## Test plan

<!--

Explain how you tested your changes. Include the exact commands that you
used to verify the change works and include screenshots/screen
recordings of the update behavior in the browser if applicable.

-->

- `pnpm test:integrations -- webpack/loader.test.ts`  
  - Confirms all existing webpack loader integration tests pass.  
- Confirms the new `@tailwindcss/webpack loader isolates cache by
resource including query` test passes, verifying that two entries
importing the same CSS file with different queries produce isolated
outputs (`dist/a.css` only contains `only-a` / `--color-red-500`, and
`dist/b.css` only contains `only-b` / `--color-blue-500`).

---------

Co-authored-by: Robin Malfait <malfait.robin@gmail.com>
2026-03-20 16:34:11 +00:00

287 lines
8.6 KiB
TypeScript

import QuickLRU from '@alloc/quick-lru'
import {
compile,
env,
Features,
Instrumentation,
normalizePath,
optimize,
Polyfills,
} from '@tailwindcss/node'
import { clearRequireCache } from '@tailwindcss/node/require-cache'
import { Scanner } from '@tailwindcss/oxide'
import fs from 'node:fs'
import path from 'node:path'
import type { LoaderContext } from 'webpack'
const DEBUG = env.DEBUG
export interface LoaderOptions {
/**
* 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 }
}
interface CacheEntry {
mtimes: Map<string, number>
compiler: null | Awaited<ReturnType<typeof compile>>
scanner: null | Scanner
candidates: Set<string>
fullRebuildPaths: string[]
}
const cache = new QuickLRU<string, CacheEntry>({ maxSize: 50 })
function getCacheKey(resourceId: string, opts: LoaderOptions): string {
return `${resourceId}:${opts.base ?? ''}:${JSON.stringify(opts.optimize)}`
}
function getContextFromCache(resourceId: string, opts: LoaderOptions): CacheEntry {
let key = getCacheKey(resourceId, opts)
if (cache.has(key)) return cache.get(key)!
let entry: CacheEntry = {
mtimes: new Map<string, number>(),
compiler: null,
scanner: null,
candidates: new Set<string>(),
fullRebuildPaths: [],
}
cache.set(key, entry)
return entry
}
export default async function tailwindLoader(
this: LoaderContext<LoaderOptions>,
source: string,
): Promise<void> {
let callback = this.async()
let options = this.getOptions() ?? {}
let inputFile = this.resourcePath
let resourceId = this.resource
let base = options.base ?? process.cwd()
let shouldOptimize = options.optimize ?? process.env.NODE_ENV === 'production'
let isCSSModuleFile = inputFile.endsWith('.module.css')
using I = new Instrumentation()
DEBUG && I.start(`[@tailwindcss/webpack] ${path.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 = !/@(import|reference|theme|variant|config|plugin|apply|tailwind)\b/.test(source)
if (canBail) {
DEBUG && I.end('Quick bail check')
DEBUG && I.end(`[@tailwindcss/webpack] ${path.relative(base, inputFile)}`)
callback(null, source)
return
}
DEBUG && I.end('Quick bail check')
}
try {
let context = getContextFromCache(resourceId, options)
let inputBasePath = path.dirname(path.resolve(inputFile))
// Whether this is the first build or not
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('Create compiler')
let compiler = await compile(source, {
from: inputFile,
base: inputBasePath,
shouldRewriteUrls: true,
onDependency: (depPath) => context.fullRebuildPaths.push(depPath),
// 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
}
// Setup the compiler if it doesn't exist yet
context.compiler ??= await createCompiler()
// Early exit if no Tailwind features are used
if (context.compiler.features === Features.None) {
DEBUG && I.end(`[@tailwindcss/webpack] ${path.relative(base, inputFile)}`)
callback(null, source)
return
}
let rebuildStrategy: 'full' | 'incremental' = 'incremental'
// Track file modification times to CSS files
DEBUG && I.start('Register full rebuild paths')
{
// Report dependencies for config files, plugins, etc.
for (let file of context.fullRebuildPaths) {
this.addDependency(path.resolve(file))
}
let files = [...context.fullRebuildPaths, inputFile]
for (let file of files) {
let changedTime: number | null = null
try {
changedTime = fs.statSync(file)?.mtimeMs ?? null
} catch {
// File might not exist
}
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)
}
}
DEBUG && I.end('Register full rebuild paths')
if (rebuildStrategy === 'full' && !isInitialBuild) {
context.compiler = await createCompiler()
}
let compiler = context.compiler
// Check if we need to process this file at all
if (
!(
compiler.features &
(Features.AtApply | Features.JsPluginCompat | Features.ThemeFunction | Features.Utilities)
)
) {
DEBUG && I.end(`[@tailwindcss/webpack] ${path.relative(base, inputFile)}`)
callback(null, source)
return
}
// Setup or update scanner if needed
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)
context.scanner = new Scanner({ sources })
DEBUG && I.end('Setup scanner')
}
// Scan for candidates if utilities are used
if (compiler.features & Features.Utilities) {
DEBUG && I.start('Scan for candidates')
for (let candidate of context.scanner.scan()) {
context.candidates.add(candidate)
}
DEBUG && I.end('Scan for candidates')
DEBUG && I.start('Register dependency messages')
// Add all found files as direct dependencies
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
}
this.addDependency(absolutePath)
}
// Register context dependencies for glob patterns
for (let glob of context.scanner.globs) {
// Skip negated patterns
if (glob.pattern[0] === '!') continue
// Avoid adding a dependency on the base directory itself
if (glob.pattern === '*' && base === glob.base) {
continue
}
this.addContextDependency(path.resolve(glob.base))
}
// Validate that source(...) paths are directories
let root = compiler.root
if (root !== 'none' && root !== null) {
let basePath = normalizePath(path.resolve(root.base, root.pattern))
try {
let stats = fs.statSync(basePath)
if (!stats.isDirectory()) {
throw new Error(
`The path given to \`source(…)\` must be a directory but got \`source(${basePath})\` instead.`,
)
}
} catch (err) {
if ((err as NodeJS.ErrnoException).code !== 'ENOENT') {
throw err
}
// Directory doesn't exist yet, which is fine
}
}
DEBUG && I.end('Register dependency messages')
}
DEBUG && I.start('Build utilities')
let css = compiler.build([...context.candidates])
DEBUG && I.end('Build utilities')
// Optionally optimize the output
let result = css
if (shouldOptimize) {
DEBUG && I.start('Optimization')
let optimized = optimize(css, {
minify: typeof shouldOptimize === 'object' ? shouldOptimize.minify : true,
})
result = optimized.code
DEBUG && I.end('Optimization')
}
DEBUG && I.end(`[@tailwindcss/webpack] ${path.relative(base, inputFile)}`)
callback(null, result)
} catch (error) {
// Clear the cache entry on error to force a full rebuild next time
let key = getCacheKey(resourceId, options)
cache.delete(key)
DEBUG && I.end(`[@tailwindcss/webpack] ${path.relative(base, inputFile)}`)
callback(error as Error)
}
}