Skip to content

Commit 2eae093

Browse files
committed
fix: cache tailwind v4 incremental css generation
1 parent 0ec3e8b commit 2eae093

5 files changed

Lines changed: 240 additions & 21 deletions

File tree

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"weapp-tailwindcss": patch
3+
---
4+
5+
优化 Tailwind CSS v4 在 Vite watch 下的热更新性能,避免已有候选集时重复扫描源码,并复用增量 CSS 生成缓存。

packages/weapp-tailwindcss/src/bundlers/shared/generator-css.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -271,8 +271,8 @@ export async function generateCssByGenerator(
271271
const generator = createWeappTailwindcssGenerator(source)
272272
return generator.generate({
273273
candidates: runtime,
274-
incrementalCache: majorVersion === 3,
275-
scanSources: majorVersion === 4,
274+
incrementalCache: majorVersion === 3 || majorVersion === 4,
275+
scanSources: majorVersion === 4 && runtime.size === 0,
276276
styleOptions: generatorStyleOptions,
277277
tailwindcssV3Compatibility: generatorOptions.tailwindcssV3Compatibility,
278278
target: generatorOptions.target,

packages/weapp-tailwindcss/src/tailwindcss/v4-engine/generator.ts

Lines changed: 199 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -1,16 +1,41 @@
1-
import type { TailwindV4Engine, TailwindV4GenerateOptions, TailwindV4ResolvedSource } from './types'
1+
import type { IStyleHandlerOptions } from '@weapp-tailwindcss/postcss/types'
2+
import type {
3+
TailwindV4Engine,
4+
TailwindV4GenerateOptions,
5+
TailwindV4GenerateTarget,
6+
TailwindV4ResolvedSource,
7+
TailwindV4SourcePattern,
8+
} from './types'
9+
import fs from 'node:fs'
210
import path from 'node:path'
311
import postcss from 'postcss'
412
import { createTailwindV4Engine as createPatchTailwindV4Engine } from 'tailwindcss-patch'
513
import { omitUndefined } from '@/utils/object'
614
import { filterUnsupportedMiniProgramTailwindV4Candidates } from './candidates'
15+
import { loadTailwindV4DesignSystem } from './design-system'
716
import { transformTailwindV4CssByTarget } from './miniprogram'
817
import { applyTailwindV3CompatibilityCss } from './tailwind-v3-compatibility'
918
import { createTailwindV4DefaultColorThemeCss } from './tailwind-v4-default-colors'
1019

1120
type TailwindV4ScanSourcePatterns = Exclude<NonNullable<TailwindV4GenerateOptions['scanSources']>, boolean>
1221
type TailwindV4ResolvedScanSources = TailwindV4GenerateOptions['scanSources']
1322

23+
const incrementalGenerateCache = new Map<string, TailwindV4IncrementalGenerateCacheEntry>()
24+
25+
interface TailwindV4IncrementalGenerateCacheEntry {
26+
seenCandidates: Set<string>
27+
classSet: Set<string>
28+
css: string
29+
rawCss: string
30+
dependencies: string[]
31+
sources: TailwindV4SourcePattern[]
32+
root: null | 'none' | {
33+
base: string
34+
pattern: string
35+
}
36+
target: TailwindV4GenerateTarget
37+
}
38+
1439
function findLeadingImportInsertionIndex(css: string) {
1540
const importPattern = /(?:^|\n)\s*@import\b[^;]*;/g
1641
let insertionIndex = 0
@@ -31,6 +56,84 @@ function applyMiniProgramTailwindV4DefaultColorCss(css: string) {
3156
return `${css.slice(0, insertionIndex)}\n${themeCss}\n${css.slice(insertionIndex)}`
3257
}
3358

59+
function collectCandidates(candidates: Iterable<string> | undefined) {
60+
return new Set(candidates ?? [])
61+
}
62+
63+
function createStableJson(value: unknown): string {
64+
if (value === undefined) {
65+
return 'undefined'
66+
}
67+
if (value === null || typeof value !== 'object') {
68+
return JSON.stringify(value)
69+
}
70+
if (Array.isArray(value)) {
71+
return `[${value.map(item => createStableJson(item)).join(',')}]`
72+
}
73+
return `{${Object.keys(value).sort().map((key) => {
74+
const record = value as Record<string, unknown>
75+
return `${JSON.stringify(key)}:${createStableJson(record[key])}`
76+
}).join(',')}}`
77+
}
78+
79+
function createDependencyFingerprint(files: string[]) {
80+
return files.map((file) => {
81+
try {
82+
const stat = fs.statSync(file)
83+
return `${file}:${stat.size}:${stat.mtimeMs}`
84+
}
85+
catch {
86+
return `${file}:missing`
87+
}
88+
}).join('|')
89+
}
90+
91+
function createIncrementalGenerateCacheKey(
92+
source: TailwindV4ResolvedSource,
93+
target: TailwindV4GenerateTarget,
94+
styleOptions: Partial<IStyleHandlerOptions> | undefined,
95+
tailwindcssV3Compatibility: boolean | undefined,
96+
) {
97+
return [
98+
source.projectRoot,
99+
source.base,
100+
createStableJson(source.baseFallbacks),
101+
source.css,
102+
createDependencyFingerprint(source.dependencies),
103+
target,
104+
createStableJson(styleOptions),
105+
createStableJson(tailwindcssV3Compatibility),
106+
].join('\0')
107+
}
108+
109+
function createCompatibleSource(
110+
source: TailwindV4ResolvedSource,
111+
target: TailwindV4GenerateTarget,
112+
tailwindcssV3Compatibility: boolean | undefined,
113+
) {
114+
const shouldApplyTailwindV3Compatibility = tailwindcssV3Compatibility ?? target === 'weapp'
115+
const filteredSourceCss = target === 'weapp'
116+
? removeUnlayeredTailwindV4PreflightImports(source.css)
117+
: source.css
118+
const sourceCss = shouldApplyTailwindV3Compatibility
119+
? applyTailwindV3CompatibilityCss(filteredSourceCss)
120+
: target === 'weapp'
121+
? applyMiniProgramTailwindV4DefaultColorCss(filteredSourceCss)
122+
: filteredSourceCss
123+
const compatibleSourceCss = removeUnsupportedThemeVendorKeyframes(sourceCss)
124+
return compatibleSourceCss === source.css ? source : { ...source, css: compatibleSourceCss }
125+
}
126+
127+
function resolveTargetCandidates(
128+
candidates: Iterable<string> | undefined,
129+
target: TailwindV4GenerateTarget,
130+
) {
131+
const collected = collectCandidates(candidates)
132+
return target === 'weapp'
133+
? filterUnsupportedMiniProgramTailwindV4Candidates(collected)
134+
: collected
135+
}
136+
34137
function parseImportSourceParam(params: string) {
35138
const match = /\bsource\(\s*(none|(['"])(.*?)\2)\s*\)/.exec(params)
36139
if (!match) {
@@ -226,32 +329,22 @@ function removeUnsupportedThemeVendorKeyframes(css: string) {
226329
}
227330

228331
export function createTailwindV4Engine(source: TailwindV4ResolvedSource): TailwindV4Engine {
229-
async function generate(options: TailwindV4GenerateOptions = {}) {
332+
async function generateOnce(
333+
generateSource: TailwindV4ResolvedSource,
334+
options: TailwindV4GenerateOptions = {},
335+
) {
230336
const {
231337
scanSources = true,
232338
styleOptions,
233339
tailwindcssV3Compatibility,
234340
target = 'weapp',
235341
...patchOptions
236342
} = options
237-
const shouldApplyTailwindV3Compatibility = tailwindcssV3Compatibility ?? target === 'weapp'
238-
const filteredSourceCss = target === 'weapp'
239-
? removeUnlayeredTailwindV4PreflightImports(source.css)
240-
: source.css
241-
const sourceCss = shouldApplyTailwindV3Compatibility
242-
? applyTailwindV3CompatibilityCss(filteredSourceCss)
243-
: target === 'weapp'
244-
? applyMiniProgramTailwindV4DefaultColorCss(filteredSourceCss)
245-
: filteredSourceCss
246-
const compatibleSourceCss = removeUnsupportedThemeVendorKeyframes(sourceCss)
247-
const candidates = target === 'weapp'
248-
? filterUnsupportedMiniProgramTailwindV4Candidates(patchOptions.candidates)
249-
: patchOptions.candidates
250-
const engine = createPatchTailwindV4Engine(
251-
compatibleSourceCss === source.css ? source : { ...source, css: compatibleSourceCss },
252-
)
343+
const compatibleSource = createCompatibleSource(generateSource, target, tailwindcssV3Compatibility)
344+
const candidates = resolveTargetCandidates(patchOptions.candidates, target)
345+
const engine = createPatchTailwindV4Engine(compatibleSource)
253346
const result = await engine.generate(omitUndefined({
254-
scanSources: resolveScanSources(source, scanSources),
347+
scanSources: resolveScanSources(compatibleSource, scanSources),
255348
...patchOptions,
256349
candidates,
257350
}))
@@ -266,6 +359,93 @@ export function createTailwindV4Engine(source: TailwindV4ResolvedSource): Tailwi
266359
}
267360
}
268361

362+
async function generateWithIncrementalCache(options: TailwindV4GenerateOptions = {}) {
363+
if ((options.sources?.length ?? 0) > 0 || options.bareArbitraryValues !== undefined || options.scanSources === true || Array.isArray(options.scanSources)) {
364+
return generateOnce(source, options)
365+
}
366+
367+
const target = options.target ?? 'weapp'
368+
const compatibleSource = createCompatibleSource(source, target, options.tailwindcssV3Compatibility)
369+
const requestedCandidates = resolveTargetCandidates(options.candidates, target)
370+
const cacheKey = createIncrementalGenerateCacheKey(
371+
compatibleSource,
372+
target,
373+
options.styleOptions,
374+
options.tailwindcssV3Compatibility,
375+
)
376+
const cached = incrementalGenerateCache.get(cacheKey)
377+
if (cached) {
378+
const missingCandidates = [...requestedCandidates].filter(candidate => !cached.seenCandidates.has(candidate))
379+
if (missingCandidates.length === 0) {
380+
return {
381+
css: cached.css,
382+
rawCss: cached.rawCss,
383+
classSet: new Set(cached.classSet),
384+
rawCandidates: new Set(cached.seenCandidates),
385+
dependencies: cached.dependencies,
386+
sources: cached.sources,
387+
root: cached.root,
388+
target: cached.target,
389+
}
390+
}
391+
392+
const designSystem = await loadTailwindV4DesignSystem(compatibleSource)
393+
const cssByCandidate = designSystem.candidatesToCss(missingCandidates)
394+
const rawCssParts: string[] = []
395+
const classSet = new Set<string>()
396+
for (let index = 0; index < missingCandidates.length; index += 1) {
397+
const candidate = missingCandidates[index]
398+
const css = cssByCandidate[index]
399+
if (candidate && typeof css === 'string' && css.trim().length > 0) {
400+
rawCssParts.push(css)
401+
classSet.add(candidate)
402+
}
403+
}
404+
const rawCss = rawCssParts.join('\n')
405+
const css = rawCss.length > 0
406+
? await transformTailwindV4CssByTarget(rawCss, target, options.styleOptions)
407+
: ''
408+
409+
for (const candidate of missingCandidates) {
410+
cached.seenCandidates.add(candidate)
411+
}
412+
for (const className of classSet) {
413+
cached.classSet.add(className)
414+
}
415+
cached.css = [cached.css, css].filter(Boolean).join('\n')
416+
cached.rawCss = [cached.rawCss, rawCss].filter(Boolean).join('\n')
417+
return {
418+
css: cached.css,
419+
rawCss: cached.rawCss,
420+
classSet: new Set(cached.classSet),
421+
rawCandidates: new Set(cached.seenCandidates),
422+
dependencies: cached.dependencies,
423+
sources: cached.sources,
424+
root: cached.root,
425+
target: cached.target,
426+
}
427+
}
428+
429+
const generated = await generateOnce(source, options)
430+
incrementalGenerateCache.set(cacheKey, {
431+
seenCandidates: new Set(requestedCandidates),
432+
classSet: new Set(generated.classSet),
433+
css: generated.css,
434+
rawCss: generated.rawCss,
435+
dependencies: generated.dependencies,
436+
sources: generated.sources,
437+
root: generated.root,
438+
target: generated.target,
439+
})
440+
return generated
441+
}
442+
443+
async function generate(options: TailwindV4GenerateOptions = {}) {
444+
return options.incrementalCache
445+
? generateWithIncrementalCache(options)
446+
: generateOnce(source, options)
447+
}
448+
269449
return {
270450
source,
271451
loadDesignSystem: createPatchTailwindV4Engine(source).loadDesignSystem,

packages/weapp-tailwindcss/src/tailwindcss/v4-engine/types.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ export type TailwindV4GenerateTarget = 'weapp' | 'web' | 'tailwind'
1414
type TailwindV4PatchGenerateOptions = Omit<PatchTailwindV4GenerateOptions, 'target' | 'styleOptions' | 'tailwindcssV3Compatibility' | 'scanSources'>
1515

1616
export interface TailwindV4GenerateOptions extends TailwindV4PatchGenerateOptions {
17+
incrementalCache?: boolean | undefined
1718
target?: TailwindV4GenerateTarget | undefined
1819
styleOptions?: Partial<IStyleHandlerOptions> | undefined
1920
tailwindcssV3Compatibility?: boolean | undefined

packages/weapp-tailwindcss/test/tailwindcss/v4-engine.test.ts

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -77,6 +77,39 @@ describe('tailwindcss v4 engine', () => {
7777
expect(transformed).not.toContain('color: 55rpx')
7878
})
7979

80+
it('can append new utilities via the v4 incremental cache without full source scans', async () => {
81+
const source = await resolveTailwindV4Source({
82+
css: MINIMAL_THEME_CSS,
83+
base: process.cwd(),
84+
})
85+
const engine = createTailwindV4Engine(source)
86+
87+
const first = await engine.generate({
88+
candidates: ['text-[88rpx]'],
89+
incrementalCache: true,
90+
scanSources: false,
91+
styleOptions: {
92+
isMainChunk: false,
93+
},
94+
})
95+
const second = await engine.generate({
96+
candidates: ['text-[88rpx]', 'text-[188rpx]'],
97+
incrementalCache: true,
98+
scanSources: false,
99+
styleOptions: {
100+
isMainChunk: false,
101+
},
102+
})
103+
104+
expect(first.classSet).toEqual(new Set(['text-[88rpx]']))
105+
expect(second.classSet).toEqual(new Set(['text-[88rpx]', 'text-[188rpx]']))
106+
expect(second.css).toContain('.text-_b88rpx_B')
107+
expect(second.css).toContain('font-size: 88rpx')
108+
expect(second.css).toContain('.text-_b188rpx_B')
109+
expect(second.css).toContain('font-size: 188rpx')
110+
expect(second.css.match(/\.text-_b88rpx_B/g) ?? []).toHaveLength(1)
111+
})
112+
80113
it('uses mini-program-safe Tailwind v4 default color variables for native v4 weapp output', async () => {
81114
const source = await resolveTailwindV4Source({
82115
css: `

0 commit comments

Comments
 (0)