Fixes #20328. ## What changed When `@tailwindcss/upgrade` is invoked from a subpackage of a workspace (e.g. `pnpm --filter …` or `cd packages/foo && pnpm exec upgrade`), it traverses `../../node_modules/…` and applies its v3 → v4 migration transform (`@tailwind utilities;` → `@import 'tailwindcss/utilities' layer(utilities);`) to files inside the installed `tailwindcss` package itself — a self-referential import that later detonates as an infinite CSS-resolution loop and reads like a "cache corruption" bug (matches the report in #19726 / #20328). Root cause: `globby`'s `isGitIgnored` only walks `.gitignore` files at or below its `cwd`. When invoked from a subpackage, the workspace-root `.gitignore` (which almost always lists `node_modules`) is never consulted, and `../../node_modules/…` paths come back as **not** ignored. The fix anchors `isGitIgnored` at the git repository root instead of the subpackage: - New helper: `gitRoot(cwd)` in `src/utils/git.ts` — thin wrapper over `git rev-parse --show-toplevel`. - `analyze(stylesheets, { base })` in `src/codemods/css/analyze.ts` now calls `isGitIgnored({ cwd: gitRoot(base) ?? base })`. Falls back to the previous behavior when not inside a git repository or when `git` is unavailable. ## Test Added an integration test that reproduces the exact scenario: a pnpm workspace with a subpackage that imports `tailwindcss/utilities.css`, upgrade run from the subpackage, then asserts `packages/css/node_modules/tailwindcss/utilities.css` still holds the pristine `@tailwind utilities;` directive. Verified the test fails on `main` and passes with this change. The 415 existing upgrade unit tests still pass. Two npm-related snapshot failures in `upgrade-errors.test.ts` (`half-upgraded v3 project to v4 (bun|npm)`) pre-exist this change — they're about newer npm's `npm notice run` output that isn't in the snapshots, unrelated to what this PR touches. --- Edit by @RobinMalfait: going to run on all [ci-all] so we can make sure it works on Windows as well. --------- Co-authored-by: Robin Malfait <malfait.robin@gmail.com>
303 lines
10 KiB
TypeScript
303 lines
10 KiB
TypeScript
import { isGitIgnored } from 'globby'
|
|
import path from 'node:path'
|
|
import postcss, { type Result } from 'postcss'
|
|
import { DefaultMap } from '../../../../tailwindcss/src/utils/default-map'
|
|
import { segment } from '../../../../tailwindcss/src/utils/segment'
|
|
import { Stylesheet, type StylesheetConnection } from '../../stylesheet'
|
|
import { gitRoot } from '../../utils/git'
|
|
import { error, highlight, relative } from '../../utils/renderer'
|
|
import { resolveCssId } from '../../utils/resolve'
|
|
|
|
export async function analyze(stylesheets: Stylesheet[], { base }: { base: string }) {
|
|
// Use the project's git root, instead of the `base` to ensure all
|
|
// `.gitignore` rules are being used.
|
|
let isIgnored = await isGitIgnored({ cwd: gitRoot(base) ?? base })
|
|
let processingQueue: (() => Promise<Result>)[] = []
|
|
let stylesheetsByFile = new DefaultMap<string, Stylesheet | null>((file) => {
|
|
// We don't want to process ignored files (like node_modules)
|
|
try {
|
|
if (isIgnored(file)) {
|
|
return null
|
|
}
|
|
} catch {
|
|
// If the file is not part of the current working directory (which can
|
|
// happen if you import `tailwindcss` and it's loading a shared file from
|
|
// pnpm) then this will throw.
|
|
return null
|
|
}
|
|
|
|
try {
|
|
let sheet = Stylesheet.loadSync(file)
|
|
|
|
// Mutate incoming stylesheets to include the newly discovered sheet
|
|
stylesheets.push(sheet)
|
|
|
|
// Queue up the processing of this stylesheet
|
|
processingQueue.push(() => processor.process(sheet.root, { from: sheet.file! }))
|
|
|
|
return sheet
|
|
} catch {
|
|
return null
|
|
}
|
|
})
|
|
|
|
// Step 1: Record which `@import` rules point to which stylesheets
|
|
// and which stylesheets are parents/children of each other
|
|
let processor = postcss([
|
|
{
|
|
postcssPlugin: 'mark-import-nodes',
|
|
AtRule: {
|
|
import(node) {
|
|
// Find what the import points to
|
|
let id = node.params.match(/['"](.*?)['"]/)?.[1]
|
|
if (!id) return
|
|
|
|
let basePath = node.source?.input.file
|
|
? path.dirname(node.source.input.file)
|
|
: process.cwd()
|
|
|
|
// Resolve the import to a file path
|
|
let resolvedPath: string | false = false
|
|
try {
|
|
// We first try to resolve the file as relative to the current file
|
|
// to mimic the behavior of `postcss-import` since that's what was
|
|
// used to resolve imports in Tailwind CSS v3.
|
|
if (id[0] !== '.') {
|
|
try {
|
|
resolvedPath = resolveCssId(`./${id}`, basePath)
|
|
} catch {}
|
|
}
|
|
|
|
if (!resolvedPath) {
|
|
resolvedPath = resolveCssId(id, basePath)
|
|
}
|
|
} catch (err) {
|
|
// Import is a URL, we don't want to process these, but also don't
|
|
// want to show an error message for them.
|
|
if (id.startsWith('http://') || id.startsWith('https://') || id.startsWith('//')) {
|
|
return
|
|
}
|
|
|
|
// Something went wrong, we can't resolve the import.
|
|
error(
|
|
`Failed to resolve import: ${highlight(id)} in ${highlight(relative(node.source?.input.file!, basePath))}. Skipping.`,
|
|
{ prefix: '↳ ' },
|
|
)
|
|
return
|
|
}
|
|
|
|
if (!resolvedPath) return
|
|
|
|
// Find the stylesheet pointing to the resolved path
|
|
let stylesheet = stylesheetsByFile.get(resolvedPath)
|
|
|
|
// If it _does not_ exist in stylesheets we don't care and skip it
|
|
// this is likely because its in node_modules or a workspace package
|
|
// that we don't want to modify
|
|
if (!stylesheet) return
|
|
|
|
// Mark the import node with the ID of the stylesheet it points to
|
|
// We will use these later to build lookup tables and modify the AST
|
|
node.raws.tailwind_destination_sheet_id = stylesheet.id
|
|
|
|
let parent = node.source?.input.file
|
|
? stylesheetsByFile.get(node.source.input.file)
|
|
: undefined
|
|
|
|
let layers: string[] = []
|
|
|
|
for (let part of segment(node.params, ' ')) {
|
|
if (!part.startsWith('layer(')) continue
|
|
if (!part.endsWith(')')) continue
|
|
|
|
layers.push(part.slice(6, -1).trim())
|
|
}
|
|
|
|
// Connect sheets together in a dependency graph
|
|
if (parent) {
|
|
let meta = { layers }
|
|
stylesheet.parents.add({ item: parent, meta })
|
|
parent.children.add({ item: stylesheet, meta })
|
|
}
|
|
},
|
|
},
|
|
},
|
|
])
|
|
|
|
// Seed the map with all the known stylesheets, and queue up the processing of
|
|
// each incoming stylesheet.
|
|
for (let sheet of stylesheets) {
|
|
if (sheet.file) {
|
|
stylesheetsByFile.set(sheet.file, sheet)
|
|
processingQueue.push(() => processor.process(sheet.root, { from: sheet.file ?? undefined }))
|
|
}
|
|
}
|
|
|
|
// Process all the stylesheets from step 1
|
|
while (processingQueue.length > 0) {
|
|
let task = processingQueue.shift()!
|
|
await task()
|
|
}
|
|
|
|
// ---
|
|
|
|
let commonPath = process.cwd()
|
|
|
|
function pathToString(path: StylesheetConnection[]) {
|
|
let parts: string[] = []
|
|
|
|
for (let connection of path) {
|
|
if (!connection.item.file) continue
|
|
|
|
let filePath = connection.item.file.replace(commonPath, '')
|
|
let layers = connection.meta.layers.join(', ')
|
|
|
|
if (layers.length > 0) {
|
|
parts.push(`${filePath} (layers: ${layers})`)
|
|
} else {
|
|
parts.push(filePath)
|
|
}
|
|
}
|
|
|
|
return parts.join(' <- ')
|
|
}
|
|
|
|
let lines: string[] = []
|
|
|
|
for (let sheet of stylesheets) {
|
|
if (!sheet.file) continue
|
|
|
|
let { convertiblePaths, nonConvertiblePaths } = sheet.analyzeImportPaths()
|
|
let isAmbiguous = convertiblePaths.length > 0 && nonConvertiblePaths.length > 0
|
|
|
|
if (!isAmbiguous) continue
|
|
|
|
sheet.canMigrate = false
|
|
|
|
let filePath = sheet.file.replace(commonPath, '')
|
|
|
|
for (let path of convertiblePaths) {
|
|
lines.push(`- ${filePath} <- ${pathToString(path)}`)
|
|
}
|
|
|
|
for (let path of nonConvertiblePaths) {
|
|
lines.push(`- ${filePath} <- ${pathToString(path)}`)
|
|
}
|
|
}
|
|
|
|
if (lines.length === 0) {
|
|
let tailwindRootLeafs = new Set<Stylesheet>()
|
|
|
|
for (let sheet of stylesheets) {
|
|
// If the current file already contains `@config`, then we can assume it's
|
|
// a Tailwind CSS root file.
|
|
sheet.root.walkAtRules('config', () => {
|
|
sheet.isTailwindRoot = true
|
|
return false
|
|
})
|
|
if (sheet.isTailwindRoot) continue
|
|
|
|
// If an `@tailwind` at-rule, or `@import "tailwindcss"` is present,
|
|
// then we can assume it's a file where Tailwind CSS might be configured.
|
|
//
|
|
// However, if 2 or more stylesheets exist with these rules that share a
|
|
// common parent, then we want to mark the common parent as the root
|
|
// stylesheet instead.
|
|
sheet.root.walkAtRules((node) => {
|
|
if (
|
|
node.name === 'tailwind' ||
|
|
(node.name === 'import' && node.params.match(/^["']tailwindcss["']/)) ||
|
|
(node.name === 'import' && node.params.match(/^["']tailwindcss\/.*?["']$/))
|
|
) {
|
|
sheet.isTailwindRoot = true
|
|
tailwindRootLeafs.add(sheet)
|
|
}
|
|
})
|
|
}
|
|
|
|
// Only a single Tailwind CSS root file exists, no need to do anything else.
|
|
if (tailwindRootLeafs.size <= 1) {
|
|
return
|
|
}
|
|
|
|
// Mark the common parent as the root file
|
|
{
|
|
// Group each sheet from tailwindRootLeafs by their common parent
|
|
let commonParents = new DefaultMap<Stylesheet, Set<Stylesheet>>(() => new Set<Stylesheet>())
|
|
|
|
// Seed common parents with leafs
|
|
for (let sheet of tailwindRootLeafs) {
|
|
commonParents.get(sheet).add(sheet)
|
|
}
|
|
|
|
// If any 2 common parents come from the same tree, then all children of
|
|
// parent A and parent B will be moved to the parent of parent A and
|
|
// parent B. Parent A and parent B will be removed.
|
|
let repeat = true
|
|
repeat: while (repeat) {
|
|
repeat = false
|
|
|
|
for (let [sheetA, childrenA] of commonParents) {
|
|
for (let [sheetB, childrenB] of commonParents) {
|
|
if (sheetA === sheetB) continue
|
|
|
|
// Ancestors from self to root. Reversed order so we find the
|
|
// nearest common parent first
|
|
//
|
|
// Including self because if you compare a sheet with its parent,
|
|
// then the parent is still the common sheet between the two. In
|
|
// this case, the parent is the root file.
|
|
let ancestorsA = [sheetA].concat(Array.from(sheetA.ancestors()).reverse())
|
|
let ancestorsB = [sheetB].concat(Array.from(sheetB.ancestors()).reverse())
|
|
|
|
for (let parentA of ancestorsA) {
|
|
for (let parentB of ancestorsB) {
|
|
if (parentA !== parentB) continue
|
|
|
|
// Found the parent
|
|
let parent = parentA
|
|
|
|
commonParents.delete(sheetA)
|
|
commonParents.delete(sheetB)
|
|
|
|
for (let child of childrenA) {
|
|
commonParents.get(parent).add(child)
|
|
}
|
|
|
|
for (let child of childrenB) {
|
|
commonParents.get(parent).add(child)
|
|
}
|
|
|
|
// Found a common parent between sheet A and sheet B. We can
|
|
// stop looking for more common parents between A and B, and
|
|
// continue with the next sheet.
|
|
repeat = true
|
|
continue repeat
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// Mark the common parent as the Tailwind CSS root file, and remove the
|
|
// flag from each leaf.
|
|
for (let [parent, children] of commonParents) {
|
|
parent.isTailwindRoot = true
|
|
|
|
for (let child of children) {
|
|
if (parent === child) continue
|
|
|
|
child.isTailwindRoot = false
|
|
}
|
|
}
|
|
return
|
|
}
|
|
}
|
|
|
|
{
|
|
let error = `You have one or more stylesheets that are imported into a utility layer and non-utility layer.\n`
|
|
error += `We cannot convert stylesheets under these conditions. Please look at the following stylesheets:\n`
|
|
|
|
throw new Error(error + lines.join('\n'))
|
|
}
|
|
}
|