Robin Malfait 1ada8e0f22
Make candidate template migrations faster (#18025)
This PR makes the migrations for templates much faster. To make this
work, I also had to move things around a bit (so you might want to check
this PR commit by commit). I also solved an issue by restructuring the
code.

### Performance

For starters, we barely applied any caching when migrating candidates
from α to β. The problem with this is that in big projects the same
candidates will appear _everywhere_, so caching is going to be useful
here.

One of the reasons why we didn't do any caching is that some migrations
were checking if a migration is actually safe to do. To do this, we were
checking the `location` (the location of the candidate in the template).
Since this location is unique for each template, caching was not
possible.

So the first order of business was to hoist the `isSafeMigration` check
up as the very first thing we do in the migration.

If we do this first, then the only remaining code relies on the
`DesignSystem`, `UserConfig` and `rawCandidate`.

In a project, the `DesignSystem` and `UserConfig` will be the same
during the migration, only the `rawCandidate` will be different which
means that we can move all this logic in a good old `DefaultMap` and
cache the heck out of it.

Running the numbers on our Tailwind Plus repo, this results in:
```
    Total seen candidates: 2 211 844
Total migrated candidates: 7 775 
               Cache hits: 1 575 700
```

That's a lot of work we _don't_ have to do. Looking at the timings, the
template migration step goes from ~45s to ~10s because of this.

Another big benefit of this is that this makes migrations _actually_
safe. Before we were checking if a migration was safe to do in specific
migrations. But other migrations were still printing the candidate which
could still result in an unsafe migration.

For example when migrating the `blur` and the `shadow` classes, the
`isSafeMigration` was used. But if the input was `!flex` then the safety
check wasn't even checked in this specific migration.

### Safe migrations

Also made some changes to the `isSafeMigration` logic itself. We used to
start by checking the location, but thinking about the problem again,
the actual big problem we were running into is classes that are short
like `blur`, and `shadow` because they could be used in other contexts
than a Tailwind CSS class.

Inverting this logic means that more specific Tailwind CSS classes will
very likely _not_ cause any issues at all.

For example:
- If you have variants: `hover:focus:flex`
- If you have arbitrary properties: `[color:red]`
- If you have arbitrary values: `bg-[red]`
- If you have a modifier: `bg-red-500/50`
- If you have a `-` in the name: `bg-red-500`

Even better if we can't parse a candidate at all, we can skip the
migrations all together.

This brings us to the issue in #17974, one of the issues was already
solved by just hoisting the `isSafeMigration`. But to make the issue was
completely solved I also made sure that in Vue attributes like
`:active="…"` are also considered unsafe (note: `:class` is allowed).

Last but not least, in case of the `!duration` that got replaced with
`duration!` was solved by verifying that the candidate actually produces
valid CSS. We can compute the signature for this class.

The reason this wasn't thrown away earlier is because we can correctly
parse `duration` but `duration` on its own doesn't exist,
`duration-<number>` does exist as a functional utility which is why it
parsed in the first place.

Fixes: #17974

## Test plan

1. Ran the tool on our Tailwind UI Templates repo to compare the new
output with the "old" behavior and there were no differences in output.
2. Ran the tool on our Tailwind Plus repo, and the template migration
step went from ~45s to ~10s.
3. Added additional tests to verify the issues in #17974 are fixed.


[ci-all] let's run this on all CI platforms...
2025-05-15 11:17:39 +00:00

145 lines
4.7 KiB
TypeScript

import { __unstable__loadDesignSystem } from '@tailwindcss/node'
import path from 'node:path'
import url from 'node:url'
import type { Candidate } from '../../../../tailwindcss/src/candidate'
import type { Config } from '../../../../tailwindcss/src/compat/plugin-api'
import type { DesignSystem } from '../../../../tailwindcss/src/design-system'
import { DefaultMap } from '../../../../tailwindcss/src/utils/default-map'
import * as version from '../../utils/version'
import { baseCandidate } from './candidates'
const __filename = url.fileURLToPath(import.meta.url)
const __dirname = path.dirname(__filename)
const LEGACY_CLASS_MAP = new Map([
['shadow', 'shadow-sm'],
['shadow-sm', 'shadow-xs'],
['shadow-xs', 'shadow-2xs'],
['inset-shadow', 'inset-shadow-sm'],
['inset-shadow-sm', 'inset-shadow-xs'],
['inset-shadow-xs', 'inset-shadow-2xs'],
['drop-shadow', 'drop-shadow-sm'],
['drop-shadow-sm', 'drop-shadow-xs'],
['rounded', 'rounded-sm'],
['rounded-sm', 'rounded-xs'],
['blur', 'blur-sm'],
['blur-sm', 'blur-xs'],
['backdrop-blur', 'backdrop-blur-sm'],
['backdrop-blur-sm', 'backdrop-blur-xs'],
['ring', 'ring-3'],
['outline', 'outline-solid'],
])
const THEME_KEYS = new Map([
['shadow', '--shadow'],
['shadow-sm', '--shadow-sm'],
['shadow-xs', '--shadow-xs'],
['shadow-2xs', '--shadow-2xs'],
['drop-shadow', '--drop-shadow'],
['drop-shadow-sm', '--drop-shadow-sm'],
['drop-shadow-xs', '--drop-shadow-xs'],
['rounded', '--radius'],
['rounded-sm', '--radius-sm'],
['rounded-xs', '--radius-xs'],
['blur', '--blur'],
['blur-sm', '--blur-sm'],
['blur-xs', '--blur-xs'],
['backdrop-blur', '--backdrop-blur'],
['backdrop-blur-sm', '--backdrop-blur-sm'],
['backdrop-blur-xs', '--backdrop-blur-xs'],
['ring', '--ring-width'],
['ring-3', '--ring-width-3'],
])
const DESIGN_SYSTEMS = new DefaultMap((base) => {
return __unstable__loadDesignSystem('@import "tailwindcss";', { base })
})
export async function migrateLegacyClasses(
designSystem: DesignSystem,
_userConfig: Config | null,
rawCandidate: string,
): Promise<string> {
// These migrations are only safe when migrating from v3 to v4.
//
// Migrating from `rounded` to `rounded-sm` once is fine (v3 -> v4). But if we
// migrate again (v4 -> v4), then `rounded-sm` would be migrated to
// `rounded-xs` which is incorrect because we already migrated this.
if (!version.isMajor(3)) {
return rawCandidate
}
let defaultDesignSystem = await DESIGN_SYSTEMS.get(__dirname)
function* migrate(rawCandidate: string) {
for (let candidate of designSystem.parseCandidate(rawCandidate)) {
// Create a base candidate string from the candidate.
// E.g.: `hover:blur!` -> `blur`
let base = baseCandidate(candidate)
let baseCandidateString = designSystem.printCandidate(base)
// Find the new base candidate string. `blur` -> `blur-sm`
let newBaseCandidateString = LEGACY_CLASS_MAP.get(baseCandidateString)
if (!newBaseCandidateString) continue
// Parse the new base candidate string into an actual candidate AST.
let [newBaseCandidate] = designSystem.parseCandidate(newBaseCandidateString)
if (!newBaseCandidate) continue
// Re-apply the variants and important flag from the original candidate.
// E.g.: `hover:blur!` -> `blur` -> `blur-sm` -> `hover:blur-sm!`
let newCandidate = structuredClone(newBaseCandidate) as Candidate
newCandidate.variants = candidate.variants
newCandidate.important = candidate.important
yield [
newCandidate,
THEME_KEYS.get(baseCandidateString),
THEME_KEYS.get(newBaseCandidateString),
] as const
}
}
for (let [toCandidate, fromThemeKey, toThemeKey] of migrate(rawCandidate)) {
if (fromThemeKey && toThemeKey) {
// Migrating something that resolves to a value in the theme.
let customFrom = designSystem.resolveThemeValue(fromThemeKey, true)
let defaultFrom = defaultDesignSystem.resolveThemeValue(fromThemeKey, true)
let customTo = designSystem.resolveThemeValue(toThemeKey, true)
let defaultTo = defaultDesignSystem.resolveThemeValue(toThemeKey)
// The new theme value is not defined, which means we can't safely
// migrate the utility.
if (customTo === undefined && defaultTo !== undefined) {
continue
}
// The "from" theme value changed compared to the default theme value.
if (customFrom !== defaultFrom) {
continue
}
// The "to" theme value changed compared to the default theme value.
if (customTo !== defaultTo) {
continue
}
}
return designSystem.printCandidate(toCandidate)
}
return rawCandidate
}