Skip to content

Commit b368b40

Browse files
Improve utility lookup performance when using v4 (#1509)
Companion PR to tailwindlabs/tailwindcss#19405 The aim of this PR is to improve the performance around looking up / compiling candidates into utilities when using v4. It does this in three stages: 1. For earlier versions (v4.0–v4.1.17) we swap out PostCSS (mostly) with our own CSS parser. The implementation is tailored to improve performance and memory usage for how we use it. This also means I've been able to optimize how we walk the AST and remove mutations in many places. There *are* still some internal APIs that use PostCSS because they interface with multiple versions of Tailwind CSS. These still use PostCSS and we translate our AST into a PostCSS AST for these cases. Eventually these APIs will also be converted to use our internal CSS AST directly — we'll support old versions by converting *from PostCSS* to our internal AST — that is for a future PR. 2. For new enough versions, since our AST here mirrors the one in Tailwind CSS, we can use the nodes directly from Tailwind CSS itself instead of generating the code inside Tailwind CSS, serializing to a string, and then finally re-parsing. This is exactly what the `candidatesToAst` API enables (will ship in Tailwind CSS v4.1.18). 3. A large portion of the candidate compiling perf is spent on color lookups. The above will help with that significantly but there's still a large amount of time analyzing declaration values for colors. This PR replaces our unwieldy color regex to pick out potential colors with a fine tuned parser. This speedup gives us a good bit more headroom for re-enabling mask utility color swatches. The overhead from parsing an extra 5k utilities is still too significant but it is now *much* lower than before. The next big improvements will need to happen in core.
1 parent 29eab26 commit b368b40

File tree

24 files changed

+2151
-208
lines changed

24 files changed

+2151
-208
lines changed

packages/tailwindcss-language-server/src/util/v4/design-system.ts

Lines changed: 17 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,5 @@
11
import type { DesignSystem } from '@tailwindcss/language-service/src/util/v4'
22

3-
import postcss from 'postcss'
43
import { createJiti } from 'jiti'
54
import * as fs from 'node:fs/promises'
65
import * as path from 'node:path'
@@ -10,6 +9,7 @@ import { pathToFileURL } from '../../utils'
109
import type { Jiti } from 'jiti/lib/types'
1110
import { assets } from './assets'
1211
import { plugins } from './plugins'
12+
import { AstNode, cloneAstNode, parse } from '@tailwindcss/language-service/src/css'
1313

1414
const HAS_V4_IMPORT = /@import\s*(?:'tailwindcss'|"tailwindcss")/
1515
const HAS_V4_THEME = /@theme\s*\{/
@@ -225,35 +225,35 @@ export async function loadDesignSystem(
225225
Object.assign(design, {
226226
dependencies: () => dependencies,
227227

228-
// TODOs:
229-
//
230-
// 1. Remove PostCSS parsing — its roughly 60% of the processing time
231-
// ex: compiling 19k classes take 650ms and 400ms of that is PostCSS
232-
//
233-
// - Replace `candidatesToCss` with a `candidatesToAst` API
234-
// First step would be to convert to a PostCSS AST by transforming the nodes directly
235-
// Then it would be to drop the PostCSS AST representation entirely in all v4 code paths
236-
compile(classes: string[]): postcss.Root[] {
228+
compile(classes: string[]): AstNode[][] {
237229
// 1. Compile any uncached classes
238-
let cache = design.storage[COMPILE_CACHE] as Record<string, postcss.Root>
230+
let cache = design.storage[COMPILE_CACHE] as Record<string, AstNode[]>
239231
let uncached = classes.filter((name) => cache[name] === undefined)
240232

241-
let css = design.candidatesToCss(uncached)
233+
let css = design.candidatesToAst
234+
? design.candidatesToAst(uncached)
235+
: design.candidatesToCss(uncached)
236+
242237
let errors: any[] = []
243238

244239
for (let [idx, cls] of uncached.entries()) {
245240
let str = css[idx]
246241

242+
if (Array.isArray(str)) {
243+
cache[cls] = str
244+
continue
245+
}
246+
247247
if (str === null) {
248-
cache[cls] = postcss.root()
248+
cache[cls] = []
249249
continue
250250
}
251251

252252
try {
253-
cache[cls] = postcss.parse(str.trimEnd())
253+
cache[cls] = parse(str.trimEnd())
254254
} catch (err) {
255255
errors.push(err)
256-
cache[cls] = postcss.root()
256+
cache[cls] = []
257257
continue
258258
}
259259
}
@@ -263,20 +263,14 @@ export async function loadDesignSystem(
263263
}
264264

265265
// 2. Pull all the classes from the cache
266-
let roots: postcss.Root[] = []
266+
let roots: AstNode[][] = []
267267

268268
for (let cls of classes) {
269-
roots.push(cache[cls].clone())
269+
roots.push(cache[cls].map(cloneAstNode))
270270
}
271271

272272
return roots
273273
},
274-
275-
toCss(nodes: postcss.Root | postcss.Node[]): string {
276-
return Array.isArray(nodes)
277-
? postcss.root({ nodes }).toString().trim()
278-
: nodes.toString().trim()
279-
},
280274
})
281275

282276
return design

packages/tailwindcss-language-server/vitest.config.mts

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -14,9 +14,16 @@ export default defineConfig({
1414
name: 'force-inline-css',
1515
enforce: 'pre',
1616
resolveId(id) {
17-
if (!id.includes('index.css')) return
1817
if (id.includes('?raw')) return
19-
return this.resolve(`${id}?raw`)
18+
19+
if (
20+
id.includes('index.css') ||
21+
id.includes('theme.css') ||
22+
id.includes('utilities.css') ||
23+
id.includes('preflight.css')
24+
) {
25+
return this.resolve(`${id}?raw`)
26+
}
2027
},
2128
},
2229
],

packages/tailwindcss-language-service/src/completionProvider.ts

Lines changed: 49 additions & 42 deletions
Original file line numberDiff line numberDiff line change
@@ -47,6 +47,8 @@ import { resolveKnownThemeKeys, resolveKnownThemeNamespaces } from './util/v4/th
4747
import { SEARCH_RANGE } from './util/constants'
4848
import { getLanguageBoundaries } from './util/getLanguageBoundaries'
4949
import { isWithinRange } from './util/isWithinRange'
50+
import { walk, WalkAction } from './util/walk'
51+
import { Declaration, toPostCSSAst } from './css'
5052

5153
let isUtil = (className) =>
5254
Array.isArray(className.__info)
@@ -2296,35 +2298,11 @@ export async function resolveCompletionItem(
22962298
let base = state.designSystem.compile([className])[0]
22972299
let root = state.designSystem.compile([[...variants, className].join(state.separator)])[0]
22982300

2299-
let rules = root.nodes.filter((node) => node.type === 'rule')
2301+
let rules = root.filter((node) => node.kind === 'rule')
23002302
if (rules.length === 0) return item
23012303

23022304
if (!item.detail) {
23032305
if (rules.length === 1) {
2304-
let decls: postcss.Declaration[] = []
2305-
2306-
// Remove any `@property` rules
2307-
base = base.clone()
2308-
base.walkAtRules((rule) => {
2309-
// Ignore declarations inside `@property` rules
2310-
if (rule.name === 'property') {
2311-
rule.remove()
2312-
}
2313-
2314-
// Ignore declarations @supports (-moz-orient: inline)
2315-
// this is a hack used for `@property` fallbacks in Firefox
2316-
if (rule.name === 'supports' && rule.params === '(-moz-orient: inline)') {
2317-
rule.remove()
2318-
}
2319-
2320-
if (
2321-
rule.name === 'supports' &&
2322-
rule.params === '(background-image: linear-gradient(in lab, red, red))'
2323-
) {
2324-
rule.remove()
2325-
}
2326-
})
2327-
23282306
let ignoredValues = new Set([
23292307
'var(--tw-border-style)',
23302308
'var(--tw-outline-style)',
@@ -2334,26 +2312,51 @@ export async function resolveCompletionItem(
23342312
'var(--tw-scale-x) var(--tw-scale-y) var(--tw-scale-z)',
23352313
])
23362314

2337-
base.walkDecls((node) => {
2338-
if (ignoredValues.has(node.value)) return
2315+
let decls: Declaration[] = []
2316+
2317+
walk(base, (node) => {
2318+
if (node.kind === 'at-rule') {
2319+
// Ignore declarations inside `@property` rules
2320+
if (node.name === '@property') {
2321+
return WalkAction.Skip
2322+
}
23392323

2340-
decls.push(node)
2324+
// Ignore declarations @supports (-moz-orient: inline)
2325+
// this is a hack used for `@property` fallbacks in Firefox
2326+
if (node.name === '@supports' && node.params === '(-moz-orient: inline)') {
2327+
return WalkAction.Skip
2328+
}
2329+
2330+
if (
2331+
node.name === '@supports' &&
2332+
node.params === '(background-image: linear-gradient(in lab, red, red))'
2333+
) {
2334+
return WalkAction.Skip
2335+
}
2336+
}
2337+
2338+
if (node.kind === 'declaration') {
2339+
if (ignoredValues.has(node.value)) return WalkAction.Continue
2340+
decls.push(node)
2341+
}
2342+
2343+
return WalkAction.Continue
23412344
})
23422345

23432346
// TODO: Hardcoding this list is really unfortunate. We should be able
23442347
// to handle this in Tailwind CSS itself.
2345-
function isOtherDecl(node: postcss.Declaration) {
2346-
if (node.prop === '--tw-leading') return false
2347-
if (node.prop === '--tw-duration') return false
2348-
if (node.prop === '--tw-ease') return false
2349-
if (node.prop === '--tw-font-weight') return false
2350-
if (node.prop === '--tw-gradient-via-stops') return false
2351-
if (node.prop === '--tw-gradient-stops') return false
2352-
if (node.prop === '--tw-tracking') return false
2353-
if (node.prop === '--tw-space-x-reverse' && node.value === '0') return false
2354-
if (node.prop === '--tw-space-y-reverse' && node.value === '0') return false
2355-
if (node.prop === '--tw-divide-x-reverse' && node.value === '0') return false
2356-
if (node.prop === '--tw-divide-y-reverse' && node.value === '0') return false
2348+
function isOtherDecl(node: Declaration) {
2349+
if (node.property === '--tw-leading') return false
2350+
if (node.property === '--tw-duration') return false
2351+
if (node.property === '--tw-ease') return false
2352+
if (node.property === '--tw-font-weight') return false
2353+
if (node.property === '--tw-gradient-via-stops') return false
2354+
if (node.property === '--tw-gradient-stops') return false
2355+
if (node.property === '--tw-tracking') return false
2356+
if (node.property === '--tw-space-x-reverse' && node.value === '0') return false
2357+
if (node.property === '--tw-space-y-reverse' && node.value === '0') return false
2358+
if (node.property === '--tw-divide-x-reverse' && node.value === '0') return false
2359+
if (node.property === '--tw-divide-y-reverse' && node.value === '0') return false
23572360

23582361
return true
23592362
}
@@ -2363,7 +2366,10 @@ export async function resolveCompletionItem(
23632366
decls = decls.filter(isOtherDecl)
23642367
}
23652368

2366-
item.detail = await jit.stringifyDecls(state, postcss.rule({ selectors: [], nodes: decls }))
2369+
let root = toPostCSSAst([{ kind: 'rule', selector: '', nodes: decls }])
2370+
let rule = root.nodes[0] as postcss.Rule
2371+
2372+
item.detail = await jit.stringifyDecls(state, rule)
23672373
} else {
23682374
item.detail = `${rules.length} rules`
23692375
}
@@ -2373,8 +2379,9 @@ export async function resolveCompletionItem(
23732379
item.documentation = {
23742380
kind: 'markdown' as typeof MarkupKind.Markdown,
23752381
value: [
2382+
//
23762383
'```css',
2377-
await jit.stringifyRoot(state, postcss.root({ nodes: rules })),
2384+
await jit.stringifyRoot(state, toPostCSSAst(rules)),
23782385
'```',
23792386
].join('\n'),
23802387
}

0 commit comments

Comments
 (0)