Skip to content

Commit b77971f

Browse files
authored
Introduce canonicalizeCandidates on the internal Design System (#19059)
This PR introduces a new `canonicalizeCandidates` function on the internal Design System. The big motivation to moving this to the core `tailwindcss` package is that we can use this in various places: - The Raycast extension - The VS Code extension / language server - 3rd party tools that use the Tailwind CSS design system APIs > This PR looks very big, but **I think it's best to go over the changes commit by commit**. Basically all of these steps already existed in the upgrade tool, but are now moved to our core `tailwindcss` package. Here is a list of all the changes: - Added a new `canonicalizeCandidates` function to the design system - Moved various migration steps to the core package. I inlined them in the same file and because of that I noticed a specific pattern (more on this later). - Moved `printCandidate` tests to the `tailwindcss` package - Setup tests for `canonicalizeCandidates` based on the existing tests in the upgrade tool. I noticed that all the migrations followed a specific pattern: 1. Parse the raw candidate into a `Candidate[]` AST 2. In a loop, try to migrate the `Candidate` to a new `Candidate` (this often handled both the `Candidate` and its `Variant[]`) 3. If something changed, print the new `Candidate` back to a string, and pass it to the next migration step. While this makes sense in isolation, we are doing a lot of repeated work by parsing, modifying, and printing the candidate multiple times. This let me to introduce the `big refactor` commit. This changes the steps to: 1. Up front, parse the raw candidate into a `Candidate[]` _once_. 2. Strip the variants and the important marker from the candidate. This means that each migration step only has to deal with the base `utility` and not care about the variants or the important marker. We can re-attach these afterwards. 3. Instead of a `rawCandidate: string`, each migration step receives an actual `Candidate` object (or a `Variant` object). 4. I also split up the migration steps for the `Candidate` and the `Variant[]`. All of this means that there is a lot less work that needs to be done. We can also cache results between migrations. So `[@media_print]:flex` and `[@media_print]:block` will result in `print:flex` and `print:block` respectively, but the `[@media_print]` part is only migrated once across both candidates. One migration step relied on the `postcss-selector-parser` package to parse selectors and attribute selectors. I didn't want to introduce a package just for this, so instead used our own `SelectorParser` in the migration and wrote a small `AttributeSelectorParser` that can parse the attribute selector into a little data structure we can work with instead. If we want, we can split this PR up into smaller pieces, but since the biggest chunk is moving existing code around, I think it's fairly doable to review as long as you go commit by commit. --- With this new API, we can turn: ``` [ 'bg-red-500', 'hover:bg-red-500', '[@media_print]:bg-red-500', 'hover:[@media_print]:bg-red-500', 'bg-red-500/100', 'hover:bg-red-500/100', '[@media_print]:bg-red-500/100', 'hover:[@media_print]:bg-red-500/100', 'bg-[var(--color-red-500)]', 'hover:bg-[var(--color-red-500)]', '[@media_print]:bg-[var(--color-red-500)]', 'hover:[@media_print]:bg-[var(--color-red-500)]', 'bg-[var(--color-red-500)]/100', 'hover:bg-[var(--color-red-500)]/100', '[@media_print]:bg-[var(--color-red-500)]/100', 'hover:[@media_print]:bg-[var(--color-red-500)]/100', 'bg-(--color-red-500)', 'hover:bg-(--color-red-500)', '[@media_print]:bg-(--color-red-500)', 'hover:[@media_print]:bg-(--color-red-500)', 'bg-(--color-red-500)/100', 'hover:bg-(--color-red-500)/100', '[@media_print]:bg-(--color-red-500)/100', 'hover:[@media_print]:bg-(--color-red-500)/100', 'bg-[color:var(--color-red-500)]', 'hover:bg-[color:var(--color-red-500)]', '[@media_print]:bg-[color:var(--color-red-500)]', 'hover:[@media_print]:bg-[color:var(--color-red-500)]', 'bg-[color:var(--color-red-500)]/100', 'hover:bg-[color:var(--color-red-500)]/100', '[@media_print]:bg-[color:var(--color-red-500)]/100', 'hover:[@media_print]:bg-[color:var(--color-red-500)]/100', 'bg-(color:--color-red-500)', 'hover:bg-(color:--color-red-500)', '[@media_print]:bg-(color:--color-red-500)', 'hover:[@media_print]:bg-(color:--color-red-500)', 'bg-(color:--color-red-500)/100', 'hover:bg-(color:--color-red-500)/100', '[@media_print]:bg-(color:--color-red-500)/100', 'hover:[@media_print]:bg-(color:--color-red-500)/100', '[background-color:var(--color-red-500)]', 'hover:[background-color:var(--color-red-500)]', '[@media_print]:[background-color:var(--color-red-500)]', 'hover:[@media_print]:[background-color:var(--color-red-500)]', '[background-color:var(--color-red-500)]/100', 'hover:[background-color:var(--color-red-500)]/100', '[@media_print]:[background-color:var(--color-red-500)]/100', 'hover:[@media_print]:[background-color:var(--color-red-500)]/100' ] ``` Into their canonicalized form: ``` [ 'bg-red-500', 'hover:bg-red-500', 'print:bg-red-500', 'hover:print:bg-red-500' ] ``` The list is also unique, so we won't end up with `bg-red-500 bg-red-500` twice. While the canonicalization itself is fairly fast, we still pay a **~1s** startup cost for some migrations (once, and cached for the entire lifetime of the design system). I would like to keep improving the performance and the kinds of migrations we do, but I think this is a good start. The cost we pay is for: 1. Generating a full list of all possible utilities based on the `getClassList` suggestions API. 2. Generating a full list of all possible variants. The canonicalization step for this list takes **~2.9ms** on my machine. Just for fun, if you use the `getClassList` API for intellisense that generates all the suggestions and their modifiers, you get a list of **263788** classes. If you canonicalize all of these, it takes **~500ms** in total. So roughly **~1.9μs** per candidate. This new API doesn't result in a performance difference for normal Tailwind CSS builds. The other potential concern is file size of the package. The generated `tailwindcss.tgz` file changed like this: ```diff - 684652 bytes (684.65 kB) + 749169 bytes (749.17 kB) ``` So the package increased by ~65 kB which I don't think is the end of the world, but it is important for the `@tailwindcss/browser` build which we don't want to grow unnecessarily. For this reason we remove some of the code for the design system conditionally such that you don't pay this cost in an environment where you will never need this API. The `@tailwindcss/browser` build looks like this: ```shell `dist/index.global.js 255.14 KB` (main) `dist/index.global.js 272.61 KB` (before this change) `dist/index.global.js 252.83 KB` (after this change, even smaller than on `main`) ```
1 parent 73628f6 commit b77971f

37 files changed

+3019
-2417
lines changed

packages/@tailwindcss-browser/tsup.config.ts

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,4 +13,20 @@ export default defineConfig({
1313
'process.env.NODE_ENV': '"production"',
1414
'process.env.FEATURES_ENV': '"stable"',
1515
},
16+
esbuildPlugins: [
17+
{
18+
name: 'patch-intellisense-apis',
19+
setup(build) {
20+
build.onLoad({ filter: /intellisense.ts$/ }, () => {
21+
return {
22+
contents: `
23+
export function getClassList() { return [] }
24+
export function getVariants() { return [] }
25+
export function canonicalizeCandidates() { return [] }
26+
`,
27+
}
28+
})
29+
},
30+
},
31+
],
1632
})
Lines changed: 1 addition & 114 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import { __unstable__loadDesignSystem } from '@tailwindcss/node'
2-
import { describe, expect, test } from 'vitest'
2+
import { expect, test } from 'vitest'
33
import { spliceChangesIntoString } from '../../utils/splice-changes-into-string'
44
import { extractRawCandidates } from './candidates'
55

@@ -82,116 +82,3 @@ test('replaces the right positions for a candidate', async () => {
8282
"
8383
`)
8484
})
85-
86-
const candidates = [
87-
// Arbitrary candidates
88-
['[color:red]', '[color:red]'],
89-
['[color:red]/50', '[color:red]/50'],
90-
['[color:red]/[0.5]', '[color:red]/[0.5]'],
91-
['[color:red]/50!', '[color:red]/50!'],
92-
['![color:red]/50', '[color:red]/50!'],
93-
['[color:red]/[0.5]!', '[color:red]/[0.5]!'],
94-
95-
// Static candidates
96-
['box-border', 'box-border'],
97-
['underline!', 'underline!'],
98-
['!underline', 'underline!'],
99-
['-inset-full', '-inset-full'],
100-
101-
// Functional candidates
102-
['bg-red-500', 'bg-red-500'],
103-
['bg-red-500/50', 'bg-red-500/50'],
104-
['bg-red-500/[0.5]', 'bg-red-500/[0.5]'],
105-
['bg-red-500!', 'bg-red-500!'],
106-
['!bg-red-500', 'bg-red-500!'],
107-
['bg-[#0088cc]/50', 'bg-[#0088cc]/50'],
108-
['bg-[#0088cc]/[0.5]', 'bg-[#0088cc]/[0.5]'],
109-
['bg-[#0088cc]!', 'bg-[#0088cc]!'],
110-
['!bg-[#0088cc]', 'bg-[#0088cc]!'],
111-
['bg-[var(--spacing)-1px]', 'bg-[var(--spacing)-1px]'],
112-
['bg-[var(--spacing)_-_1px]', 'bg-[var(--spacing)-1px]'],
113-
['bg-[var(--_spacing)]', 'bg-(--_spacing)'],
114-
['bg-(--_spacing)', 'bg-(--_spacing)'],
115-
['bg-[var(--\_spacing)]', 'bg-(--_spacing)'],
116-
['bg-(--\_spacing)', 'bg-(--_spacing)'],
117-
['bg-[-1px_-1px]', 'bg-[-1px_-1px]'],
118-
['p-[round(to-zero,1px)]', 'p-[round(to-zero,1px)]'],
119-
['w-1/2', 'w-1/2'],
120-
['p-[calc((100vw-theme(maxWidth.2xl))_/_2)]', 'p-[calc((100vw-theme(maxWidth.2xl))/2)]'],
121-
122-
// Keep spaces in strings
123-
['content-["hello_world"]', 'content-["hello_world"]'],
124-
['content-[____"hello_world"___]', 'content-["hello_world"]'],
125-
126-
// Do not escape underscores for url() and CSS variable in var()
127-
['bg-[no-repeat_url(/image_13.png)]', 'bg-[no-repeat_url(/image_13.png)]'],
128-
[
129-
'bg-[var(--spacing-0_5,_var(--spacing-1_5,_3rem))]',
130-
'bg-(--spacing-0_5,var(--spacing-1_5,3rem))',
131-
],
132-
]
133-
134-
const variants = [
135-
['', ''], // no variant
136-
['*:', '*:'],
137-
['focus:', 'focus:'],
138-
['group-focus:', 'group-focus:'],
139-
140-
['hover:focus:', 'hover:focus:'],
141-
['hover:group-focus:', 'hover:group-focus:'],
142-
['group-hover:focus:', 'group-hover:focus:'],
143-
['group-hover:group-focus:', 'group-hover:group-focus:'],
144-
145-
['min-[10px]:', 'min-[10px]:'],
146-
147-
// Normalize spaces
148-
['min-[calc(1000px_+_12em)]:', 'min-[calc(1000px+12em)]:'],
149-
['min-[calc(1000px_+12em)]:', 'min-[calc(1000px+12em)]:'],
150-
['min-[calc(1000px+_12em)]:', 'min-[calc(1000px+12em)]:'],
151-
['min-[calc(1000px___+___12em)]:', 'min-[calc(1000px+12em)]:'],
152-
153-
['peer-[&_p]:', 'peer-[&_p]:'],
154-
['peer-[&_p]:hover:', 'peer-[&_p]:hover:'],
155-
['hover:peer-[&_p]:', 'hover:peer-[&_p]:'],
156-
['hover:peer-[&_p]:focus:', 'hover:peer-[&_p]:focus:'],
157-
['peer-[&:hover]:peer-[&_p]:', 'peer-[&:hover]:peer-[&_p]:'],
158-
159-
['[p]:', '[p]:'],
160-
['[_p_]:', '[p]:'],
161-
['has-[p]:', 'has-[p]:'],
162-
['has-[_p_]:', 'has-[p]:'],
163-
164-
// Simplify `&:is(p)` to `p`
165-
['[&:is(p)]:', '[p]:'],
166-
['[&:is(_p_)]:', '[p]:'],
167-
['has-[&:is(p)]:', 'has-[p]:'],
168-
['has-[&:is(_p_)]:', 'has-[p]:'],
169-
170-
// Handle special `@` variants. These shouldn't be printed as `@-`
171-
['@xl:', '@xl:'],
172-
['@[123px]:', '@[123px]:'],
173-
]
174-
175-
let combinations: [string, string][] = []
176-
177-
for (let [inputVariant, outputVariant] of variants) {
178-
for (let [inputCandidate, outputCandidate] of candidates) {
179-
combinations.push([`${inputVariant}${inputCandidate}`, `${outputVariant}${outputCandidate}`])
180-
}
181-
}
182-
183-
describe('printCandidate()', () => {
184-
test.each(combinations)('%s -> %s', async (candidate: string, result: string) => {
185-
let designSystem = await __unstable__loadDesignSystem('@import "tailwindcss";', {
186-
base: __dirname,
187-
})
188-
189-
let candidates = designSystem.parseCandidate(candidate)
190-
191-
// Sometimes we will have a functional and a static candidate for the same
192-
// raw input string (e.g. `-inset-full`). Dedupe in this case.
193-
let cleaned = new Set([...candidates].map((c) => designSystem.printCandidate(c)))
194-
195-
expect([...cleaned]).toEqual([result])
196-
})
197-
})

0 commit comments

Comments
 (0)