From 56e7f3b2c294e6fb1934fb41fd5fdfc3b5af6965 Mon Sep 17 00:00:00 2001 From: Robin Malfait Date: Tue, 21 Oct 2025 16:55:30 +0200 Subject: [PATCH] Improve memory usage during canonicalization (#19171) This PR drastically improves the memory usage when performing canonicalization if you swap out the underlying DesignSystem often. This will be most noticeable in Intellisense. The big issue we had is that we used module scoped Map objects where we cache data based on the DesignSystem. If you then create new design systems (often), then the cache would just keep growing and growing. This PR solves that by essentially storing all the caches on the Design System itself. This way, when you throw away a Design System, all the caches go with it. Another approach would've been to use a WeakMap, but then we would have to make sure that no strong references to the DesignSystem exist anywhere else, otherwise we would still have the same memory issues. Note: make sure to go commit by commit and use `?w=1` to ignore whitespace changes. ## Test plan 1. All existing tests pass Not super sure how to test this as part of the test suite without making it slow But I also don't think that's super necessary either. Here is an experiment I did where I introduce 5 design systems: image On the current `main` branch, this looks like: image In this PR, the memory usage looks like: image The memory usage is stable, but to actually prove that we can still track multiple design systems, let's track them all in a `Set` so garbage collection cannot get rid of the unused design system objects: image Now we're sort of back to the current situation on `main`: image --- .../template/migrate-arbitrary-variants.ts | 18 +- .../src/codemods/template/migrate.ts | 12 +- .../src/canonicalize-candidates.ts | 911 +++++++++++++++--- packages/tailwindcss/src/design-system.ts | 7 + .../src/expand-declaration.test.ts | 2 +- .../tailwindcss/src/expand-declaration.ts | 2 +- packages/tailwindcss/src/signatures.ts | 561 ----------- 7 files changed, 799 insertions(+), 714 deletions(-) delete mode 100644 packages/tailwindcss/src/signatures.ts diff --git a/packages/@tailwindcss-upgrade/src/codemods/template/migrate-arbitrary-variants.ts b/packages/@tailwindcss-upgrade/src/codemods/template/migrate-arbitrary-variants.ts index c1b9669ac..58efb0421 100644 --- a/packages/@tailwindcss-upgrade/src/codemods/template/migrate-arbitrary-variants.ts +++ b/packages/@tailwindcss-upgrade/src/codemods/template/migrate-arbitrary-variants.ts @@ -1,23 +1,23 @@ import { cloneCandidate } from '../../../../tailwindcss/src/candidate' -import { createSignatureOptions } from '../../../../tailwindcss/src/canonicalize-candidates' +import { + PRE_COMPUTED_VARIANTS_KEY, + prepareDesignSystemStorage, + VARIANT_SIGNATURE_KEY, +} from '../../../../tailwindcss/src/canonicalize-candidates' import type { Config } from '../../../../tailwindcss/src/compat/plugin-api' import type { DesignSystem } from '../../../../tailwindcss/src/design-system' -import { - computeVariantSignature, - preComputedVariants, -} from '../../../../tailwindcss/src/signatures' import type { Writable } from '../../../../tailwindcss/src/types' import { replaceObject } from '../../../../tailwindcss/src/utils/replace-object' import { walkVariants } from '../../utils/walk-variants' export function migrateArbitraryVariants( - designSystem: DesignSystem, + baseDesignSystem: DesignSystem, _userConfig: Config | null, rawCandidate: string, ): string { - let signatureOptions = createSignatureOptions(designSystem) - let signatures = computeVariantSignature.get(signatureOptions) - let variants = preComputedVariants.get(signatureOptions) + let designSystem = prepareDesignSystemStorage(baseDesignSystem) + let signatures = designSystem.storage[VARIANT_SIGNATURE_KEY] + let variants = designSystem.storage[PRE_COMPUTED_VARIANTS_KEY] for (let readonlyCandidate of designSystem.parseCandidate(rawCandidate)) { // We are only interested in the variants diff --git a/packages/@tailwindcss-upgrade/src/codemods/template/migrate.ts b/packages/@tailwindcss-upgrade/src/codemods/template/migrate.ts index 6060d8020..2b39094d4 100644 --- a/packages/@tailwindcss-upgrade/src/codemods/template/migrate.ts +++ b/packages/@tailwindcss-upgrade/src/codemods/template/migrate.ts @@ -1,9 +1,12 @@ import fs from 'node:fs/promises' import path, { extname } from 'node:path' -import { createSignatureOptions } from '../../../../tailwindcss/src/canonicalize-candidates' +import { + createSignatureOptions, + prepareDesignSystemStorage, + UTILITY_SIGNATURE_KEY, +} from '../../../../tailwindcss/src/canonicalize-candidates' import type { Config } from '../../../../tailwindcss/src/compat/plugin-api' import type { DesignSystem } from '../../../../tailwindcss/src/design-system' -import { computeUtilitySignature } from '../../../../tailwindcss/src/signatures' import { DefaultMap } from '../../../../tailwindcss/src/utils/default-map' import { spliceChangesIntoString, type StringChange } from '../../utils/splice-changes-into-string' import { extractRawCandidates } from './candidates' @@ -40,7 +43,8 @@ export const DEFAULT_MIGRATIONS: Migration[] = [ migrateModernizeArbitraryValues, ] -let migrateCached = new DefaultMap((designSystem: DesignSystem) => { +let migrateCached = new DefaultMap((baseDesignSystem: DesignSystem) => { + let designSystem = prepareDesignSystemStorage(baseDesignSystem) let options = createSignatureOptions(designSystem) return new DefaultMap((userConfig: Config | null) => { @@ -57,7 +61,7 @@ let migrateCached = new DefaultMap((designSystem: DesignSystem) => { // Verify that the candidate actually makes sense at all. E.g.: `duration` // is not a valid candidate, but it will parse because `duration-` // exists. - let signature = computeUtilitySignature.get(options).get(rawCandidate) + let signature = designSystem.storage[UTILITY_SIGNATURE_KEY].get(options).get(rawCandidate) if (typeof signature !== 'string') return original return rawCandidate diff --git a/packages/tailwindcss/src/canonicalize-candidates.ts b/packages/tailwindcss/src/canonicalize-candidates.ts index 3977ba2ec..996859c53 100644 --- a/packages/tailwindcss/src/canonicalize-candidates.ts +++ b/packages/tailwindcss/src/canonicalize-candidates.ts @@ -1,7 +1,10 @@ +import { substituteAtApply } from './apply' +import { atRule, cloneAstNode, styleRule, toCss, type AstNode } from './ast' import * as AttributeSelectorParser from './attribute-selector-parser' import { cloneCandidate, cloneVariant, + printArbitraryValue, printModifier, type Candidate, type CandidateModifier, @@ -9,18 +12,12 @@ import { type Variant, } from './candidate' import { keyPathToCssProperty } from './compat/apply-config-to-theme' -import type { DesignSystem } from './design-system' +import { constantFoldDeclaration } from './constant-fold-declaration' +import type { DesignSystem as BaseDesignSystem } from './design-system' +import { CompileAstFlags } from './design-system' +import { expandDeclaration } from './expand-declaration' import * as SelectorParser from './selector-parser' -import { - computeUtilityProperties, - computeUtilitySignature, - computeVariantSignature, - preComputedUtilities, - preComputedVariants, - SignatureFeatures, - staticUtilitiesByPropertyAndValue, - type SignatureOptions, -} from './signatures' +import { ThemeOptions } from './theme' import type { Writable } from './types' import { DefaultMap } from './utils/default-map' import { dimensions } from './utils/dimensions' @@ -74,61 +71,120 @@ interface InternalCanonicalizeOptions { signatureOptions: SignatureOptions } -const signatureOptionsCache = new DefaultMap((designSystem: DesignSystem) => { - return new DefaultMap((rem: number | null = null) => { +interface DesignSystem extends BaseDesignSystem { + storage: { + [SIGNATURE_OPTIONS_KEY]: DefaultMap< + number | null, // Rem value + DefaultMap + > + [INTERNAL_OPTIONS_KEY]: DefaultMap< + SignatureOptions, + DefaultMap + > + [CANONICALIZE_CANDIDATE_KEY]: DefaultMap< + InternalCanonicalizeOptions, + DefaultMap + > + [CANONICALIZE_VARIANT_KEY]: DefaultMap< + InternalCanonicalizeOptions, + DefaultMap + > + [CANONICALIZE_UTILITY_KEY]: DefaultMap> + [CONVERTER_KEY]: (input: string, options?: Convert) => [string, CandidateModifier | null] + [SPACING_KEY]: DefaultMap | null + [UTILITY_SIGNATURE_KEY]: DefaultMap> + [STATIC_UTILITIES_KEY]: DefaultMap< + SignatureOptions, + DefaultMap>> + > + [UTILITY_PROPERTIES_KEY]: DefaultMap< + SignatureOptions, + DefaultMap>> + > + [PRE_COMPUTED_UTILITIES_KEY]: DefaultMap> + [VARIANT_SIGNATURE_KEY]: DefaultMap + [PRE_COMPUTED_VARIANTS_KEY]: DefaultMap + } +} + +export function prepareDesignSystemStorage(baseDesignSystem: BaseDesignSystem): DesignSystem { + let designSystem = baseDesignSystem as DesignSystem + + designSystem.storage[SIGNATURE_OPTIONS_KEY] ??= createSignatureOptionsCache() + designSystem.storage[INTERNAL_OPTIONS_KEY] ??= createInternalOptionsCache(designSystem) + designSystem.storage[CANONICALIZE_CANDIDATE_KEY] ??= createCanonicalizeCandidateCache() + designSystem.storage[CANONICALIZE_VARIANT_KEY] ??= createCanonicalizeVariantCache() + designSystem.storage[CANONICALIZE_UTILITY_KEY] ??= createCanonicalizeUtilityCache() + designSystem.storage[CONVERTER_KEY] ??= createConverterCache(designSystem) + designSystem.storage[SPACING_KEY] ??= createSpacingCache(designSystem) + designSystem.storage[UTILITY_SIGNATURE_KEY] ??= createUtilitySignatureCache(designSystem) + designSystem.storage[STATIC_UTILITIES_KEY] ??= createStaticUtilitiesCache() + designSystem.storage[UTILITY_PROPERTIES_KEY] ??= createUtilityPropertiesCache(designSystem) + designSystem.storage[PRE_COMPUTED_UTILITIES_KEY] ??= createPreComputedUtilitiesCache(designSystem) + designSystem.storage[VARIANT_SIGNATURE_KEY] ??= createVariantSignatureCache(designSystem) + designSystem.storage[PRE_COMPUTED_VARIANTS_KEY] ??= createPreComputedVariantsCache(designSystem) + + return designSystem +} + +const SIGNATURE_OPTIONS_KEY = Symbol() +function createSignatureOptionsCache(): DesignSystem['storage'][typeof SIGNATURE_OPTIONS_KEY] { + return new DefaultMap((rem: number | null) => { return new DefaultMap((features: SignatureFeatures) => { - return { designSystem, rem, features } satisfies SignatureOptions + return { rem, features } satisfies SignatureOptions }) }) -}) +} export function createSignatureOptions( - designSystem: DesignSystem, + baseDesignSystem: BaseDesignSystem, options?: CanonicalizeOptions, ): SignatureOptions { let features = SignatureFeatures.None if (options?.collapse) features |= SignatureFeatures.ExpandProperties if (options?.logicalToPhysical) features |= SignatureFeatures.LogicalToPhysical - return signatureOptionsCache - .get(designSystem) - .get(options?.rem ?? null) - .get(features) + let designSystem = prepareDesignSystemStorage(baseDesignSystem) + + return designSystem.storage[SIGNATURE_OPTIONS_KEY].get(options?.rem ?? null).get(features) } -const internalOptionsCache = new DefaultMap((designSystem: DesignSystem) => { +const INTERNAL_OPTIONS_KEY = Symbol() +function createInternalOptionsCache( + designSystem: DesignSystem, +): DesignSystem['storage'][typeof INTERNAL_OPTIONS_KEY] { return new DefaultMap((signatureOptions: SignatureOptions) => { return new DefaultMap((features: Features) => { - return { - features, - designSystem, - signatureOptions, - } satisfies InternalCanonicalizeOptions + return { features, designSystem, signatureOptions } satisfies InternalCanonicalizeOptions }) }) -}) +} function createCanonicalizeOptions( - designSystem: DesignSystem, + baseDesignSystem: BaseDesignSystem, signatureOptions: SignatureOptions, options?: CanonicalizeOptions, ) { let features = Features.None if (options?.collapse) features |= Features.CollapseUtilities - return internalOptionsCache.get(designSystem).get(signatureOptions).get(features) + let designSystem = prepareDesignSystemStorage(baseDesignSystem) + + return designSystem.storage[INTERNAL_OPTIONS_KEY].get(signatureOptions).get(features) } export function canonicalizeCandidates( - designSystem: DesignSystem, + baseDesignSystem: BaseDesignSystem, candidates: string[], options?: CanonicalizeOptions, ): string[] { - let signatureOptions = createSignatureOptions(designSystem, options) - let canonicalizeOptions = createCanonicalizeOptions(designSystem, signatureOptions, options) + let signatureOptions = createSignatureOptions(baseDesignSystem, options) + let canonicalizeOptions = createCanonicalizeOptions(baseDesignSystem, signatureOptions, options) + + let designSystem = prepareDesignSystemStorage(baseDesignSystem) let result = new Set() - let cache = canonicalizeCandidateCache.get(canonicalizeOptions) + let cache = designSystem.storage[CANONICALIZE_CANDIDATE_KEY].get(canonicalizeOptions) for (let candidate of candidates) { result.add(cache.get(candidate)) } @@ -140,6 +196,7 @@ export function canonicalizeCandidates( function collapseCandidates(options: InternalCanonicalizeOptions, candidates: string[]): string[] { if (candidates.length <= 1) return candidates + let designSystem = options.designSystem // To keep things simple, we group candidates such that we only collapse // candidates with the same variants and important modifier together. @@ -186,8 +243,9 @@ function collapseCandidates(options: InternalCanonicalizeOptions, candidates: st function collapseGroup(candidates: string[]) { let signatureOptions = options.signatureOptions - let computeUtilitiesPropertiesLookup = computeUtilityProperties.get(signatureOptions) - let staticUtilities = staticUtilitiesByPropertyAndValue.get(signatureOptions) + let computeUtilitiesPropertiesLookup = + designSystem.storage[UTILITY_PROPERTIES_KEY].get(signatureOptions) + let staticUtilities = designSystem.storage[STATIC_UTILITIES_KEY].get(signatureOptions) // For each candidate, compute the used properties and values. E.g.: `mt-1` → `margin-top` → `0.25rem` // @@ -269,7 +327,9 @@ function collapseCandidates(options: InternalCanonicalizeOptions, candidates: st let potentialReplacements = combo.flatMap((idx) => otherUtilities[idx]).reduce(intersection) - let collapsedSignature = computeUtilitySignature.get(signatureOptions).get( + let collapsedSignature = designSystem.storage[UTILITY_SIGNATURE_KEY].get( + signatureOptions, + ).get( combo .map((idx) => candidates[idx]) .sort((a, z) => a.localeCompare(z)) // Sort to increase cache hits @@ -277,7 +337,8 @@ function collapseCandidates(options: InternalCanonicalizeOptions, candidates: st ) for (let replacement of potentialReplacements) { - let signature = computeUtilitySignature.get(signatureOptions).get(replacement) + let signature = + designSystem.storage[UTILITY_SIGNATURE_KEY].get(signatureOptions).get(replacement) if (signature !== collapsedSignature) continue // Not a safe replacement // We can replace all items in the combo with the replacement @@ -300,66 +361,69 @@ function collapseCandidates(options: InternalCanonicalizeOptions, candidates: st } } -const canonicalizeCandidateCache = new DefaultMap((options: InternalCanonicalizeOptions) => { - let ds = options.designSystem - let prefix = ds.theme.prefix ? `${ds.theme.prefix}:` : '' - let variantCache = canonicalizeVariantCache.get(options) - let utilityCache = canonicalizeUtilityCache.get(options) +const CANONICALIZE_CANDIDATE_KEY = Symbol() +function createCanonicalizeCandidateCache(): DesignSystem['storage'][typeof CANONICALIZE_CANDIDATE_KEY] { + return new DefaultMap((options: InternalCanonicalizeOptions) => { + let ds = options.designSystem + let prefix = ds.theme.prefix ? `${ds.theme.prefix}:` : '' + let variantCache = ds.storage[CANONICALIZE_VARIANT_KEY].get(options) + let utilityCache = ds.storage[CANONICALIZE_UTILITY_KEY].get(options) - return new DefaultMap((rawCandidate: string, self) => { - for (let candidate of ds.parseCandidate(rawCandidate)) { - let variants = candidate.variants - .slice() - .reverse() - .flatMap((variant) => variantCache.get(variant)) - let important = candidate.important + return new DefaultMap((rawCandidate: string, self) => { + for (let candidate of ds.parseCandidate(rawCandidate)) { + let variants = candidate.variants + .slice() + .reverse() + .flatMap((variant) => variantCache.get(variant)) + let important = candidate.important - // Canonicalize the base candidate (utility), and re-attach the variants - // and important flag afterwards. This way we can maximize cache hits for - // the base candidate and each individual variant. - if (important || variants.length > 0) { - let canonicalizedUtility = self.get( - ds.printCandidate({ ...candidate, variants: [], important: false }), - ) + // Canonicalize the base candidate (utility), and re-attach the variants + // and important flag afterwards. This way we can maximize cache hits for + // the base candidate and each individual variant. + if (important || variants.length > 0) { + let canonicalizedUtility = self.get( + ds.printCandidate({ ...candidate, variants: [], important: false }), + ) - // Rebuild the final candidate - let result = canonicalizedUtility + // Rebuild the final candidate + let result = canonicalizedUtility - // Remove the prefix if there are variants, because the variants exist - // between the prefix and the base candidate. - if (ds.theme.prefix !== null && variants.length > 0) { - result = result.slice(prefix.length) + // Remove the prefix if there are variants, because the variants exist + // between the prefix and the base candidate. + if (ds.theme.prefix !== null && variants.length > 0) { + result = result.slice(prefix.length) + } + + // Re-attach the variants + if (variants.length > 0) { + result = `${variants.map((v) => ds.printVariant(v)).join(':')}:${result}` + } + + // Re-attach the important flag + if (important) { + result += '!' + } + + // Re-attach the prefix if there were variants + if (ds.theme.prefix !== null && variants.length > 0) { + result = `${prefix}${result}` + } + + return result } - // Re-attach the variants - if (variants.length > 0) { - result = `${variants.map((v) => ds.printVariant(v)).join(':')}:${result}` + // We are guaranteed to have no variants and no important flag, just the + // base candidate left to canonicalize. + let result = utilityCache.get(rawCandidate) + if (result !== rawCandidate) { + return result } - - // Re-attach the important flag - if (important) { - result += '!' - } - - // Re-attach the prefix if there were variants - if (ds.theme.prefix !== null && variants.length > 0) { - result = `${prefix}${result}` - } - - return result } - // We are guaranteed to have no variants and no important flag, just the - // base candidate left to canonicalize. - let result = utilityCache.get(rawCandidate) - if (result !== rawCandidate) { - return result - } - } - - return rawCandidate + return rawCandidate + }) }) -}) +} type VariantCanonicalizationFunction = ( variant: Variant, @@ -373,25 +437,28 @@ const VARIANT_CANONICALIZATIONS: VariantCanonicalizationFunction[] = [ arbitraryVariants, ] -const canonicalizeVariantCache = new DefaultMap((options: InternalCanonicalizeOptions) => { - return new DefaultMap((variant: Variant): Variant[] => { - let replacement = [variant] - for (let fn of VARIANT_CANONICALIZATIONS) { - for (let current of replacement.splice(0)) { - // A single variant can result in multiple variants, e.g.: - // `[&>[data-selected]]:flex` → `*:data-selected:flex` - let result = fn(cloneVariant(current), options) - if (Array.isArray(result)) { - replacement.push(...result) - continue - } else { - replacement.push(result) +const CANONICALIZE_VARIANT_KEY = Symbol() +function createCanonicalizeVariantCache(): DesignSystem['storage'][typeof CANONICALIZE_VARIANT_KEY] { + return new DefaultMap((options: InternalCanonicalizeOptions) => { + return new DefaultMap((variant: Variant): Variant[] => { + let replacement = [variant] + for (let fn of VARIANT_CANONICALIZATIONS) { + for (let current of replacement.splice(0)) { + // A single variant can result in multiple variants, e.g.: + // `[&>[data-selected]]:flex` → `*:data-selected:flex` + let result = fn(cloneVariant(current), options) + if (Array.isArray(result)) { + replacement.push(...result) + continue + } else { + replacement.push(result) + } } } - } - return replacement + return replacement + }) }) -}) +} type UtilityCanonicalizationFunction = ( candidate: Candidate, @@ -409,25 +476,28 @@ const UTILITY_CANONICALIZATIONS: UtilityCanonicalizationFunction[] = [ optimizeModifier, ] -const canonicalizeUtilityCache = new DefaultMap((options: InternalCanonicalizeOptions) => { - let designSystem = options.designSystem - return new DefaultMap((rawCandidate: string): string => { - for (let readonlyCandidate of designSystem.parseCandidate(rawCandidate)) { - let replacement = cloneCandidate(readonlyCandidate) as Writable +const CANONICALIZE_UTILITY_KEY = Symbol() +function createCanonicalizeUtilityCache(): DesignSystem['storage'][typeof CANONICALIZE_UTILITY_KEY] { + return new DefaultMap((options: InternalCanonicalizeOptions) => { + let designSystem = options.designSystem + return new DefaultMap((rawCandidate: string): string => { + for (let readonlyCandidate of designSystem.parseCandidate(rawCandidate)) { + let replacement = cloneCandidate(readonlyCandidate) as Writable - for (let fn of UTILITY_CANONICALIZATIONS) { - replacement = fn(replacement, options) + for (let fn of UTILITY_CANONICALIZATIONS) { + replacement = fn(replacement, options) + } + + let canonicalizedCandidate = designSystem.printCandidate(replacement) + if (rawCandidate !== canonicalizedCandidate) { + return canonicalizedCandidate + } } - let canonicalizedCandidate = designSystem.printCandidate(replacement) - if (rawCandidate !== canonicalizedCandidate) { - return canonicalizedCandidate - } - } - - return rawCandidate + return rawCandidate + }) }) -}) +} // ---- @@ -456,7 +526,7 @@ const enum Convert { } function themeToVarUtility(candidate: Candidate, options: InternalCanonicalizeOptions): Candidate { - let convert = converterCache.get(options.designSystem) + let convert = options.designSystem.storage[CONVERTER_KEY] if (candidate.kind === 'arbitrary') { let [newValue, modifier] = convert( @@ -491,7 +561,7 @@ function themeToVarVariant( variant: Variant, options: InternalCanonicalizeOptions, ): Variant | Variant[] { - let convert = converterCache.get(options.designSystem) + let convert = options.designSystem.storage[CONVERTER_KEY] let iterator = walkVariants(variant) for (let [variant] of iterator) { @@ -511,8 +581,11 @@ function themeToVarVariant( return variant } -const converterCache = new DefaultMap((ds: DesignSystem) => { - return createConverter(ds) +const CONVERTER_KEY = Symbol() +function createConverterCache( + designSystem: DesignSystem, +): DesignSystem['storage'][typeof CONVERTER_KEY] { + return createConverter(designSystem) function createConverter(designSystem: DesignSystem) { function convert(input: string, options = Convert.All): [string, CandidateModifier | null] { @@ -668,7 +741,7 @@ const converterCache = new DefaultMap((ds: DesignSystem) => { return convert } -}) +} function substituteFunctionsInValue( ast: ValueParser.ValueAstNode[], @@ -805,8 +878,11 @@ function printUnprefixedCandidate(designSystem: DesignSystem, candidate: Candida // ---- -const spacing = new DefaultMap | null>((ds) => { - let spacingMultiplier = ds.resolveThemeValue('--spacing') +const SPACING_KEY = Symbol() +function createSpacingCache( + designSystem: DesignSystem, +): DesignSystem['storage'][typeof SPACING_KEY] { + let spacingMultiplier = designSystem.resolveThemeValue('--spacing') if (spacingMultiplier === undefined) return null let parsed = dimensions.get(spacingMultiplier) @@ -823,7 +899,7 @@ const spacing = new DefaultMap | return myValue / value }) -}) +} function arbitraryUtilities(candidate: Candidate, options: InternalCanonicalizeOptions): Candidate { // We are only interested in arbitrary properties and arbitrary values @@ -837,8 +913,8 @@ function arbitraryUtilities(candidate: Candidate, options: InternalCanonicalizeO } let designSystem = options.designSystem - let utilities = preComputedUtilities.get(options.signatureOptions) - let signatures = computeUtilitySignature.get(options.signatureOptions) + let utilities = designSystem.storage[PRE_COMPUTED_UTILITIES_KEY].get(options.signatureOptions) + let signatures = designSystem.storage[UTILITY_SIGNATURE_KEY].get(options.signatureOptions) let targetCandidateString = designSystem.printCandidate(candidate) @@ -914,7 +990,7 @@ function arbitraryUtilities(candidate: Candidate, options: InternalCanonicalizeO candidate.kind === 'arbitrary' ? candidate.value : (candidate.value?.value ?? null) if (value === null) return - let spacingMultiplier = spacing.get(designSystem)?.get(value) ?? null + let spacingMultiplier = designSystem.storage[SPACING_KEY]?.get(value) ?? null let rootPrefix = '' if (spacingMultiplier !== null && spacingMultiplier < 0) { rootPrefix = '-' @@ -1049,8 +1125,8 @@ function bareValueUtilities(candidate: Candidate, options: InternalCanonicalizeO } let designSystem = options.designSystem - let utilities = preComputedUtilities.get(options.signatureOptions) - let signatures = computeUtilitySignature.get(options.signatureOptions) + let utilities = designSystem.storage[PRE_COMPUTED_UTILITIES_KEY].get(options.signatureOptions) + let signatures = designSystem.storage[UTILITY_SIGNATURE_KEY].get(options.signatureOptions) let targetCandidateString = designSystem.printCandidate(candidate) @@ -1127,7 +1203,7 @@ function deprecatedUtilities( options: InternalCanonicalizeOptions, ): Candidate { let designSystem = options.designSystem - let signatures = computeUtilitySignature.get(options.signatureOptions) + let signatures = designSystem.storage[UTILITY_SIGNATURE_KEY].get(options.signatureOptions) let targetCandidateString = printUnprefixedCandidate(designSystem, candidate) @@ -1154,8 +1230,8 @@ function arbitraryVariants( options: InternalCanonicalizeOptions, ): Variant | Variant[] { let designSystem = options.designSystem - let signatures = computeVariantSignature.get(options.signatureOptions) - let variants = preComputedVariants.get(options.signatureOptions) + let signatures = designSystem.storage[VARIANT_SIGNATURE_KEY] + let variants = designSystem.storage[PRE_COMPUTED_VARIANTS_KEY] let iterator = walkVariants(variant) for (let [variant] of iterator) { @@ -1185,7 +1261,7 @@ function dropUnnecessaryDataTypes( options: InternalCanonicalizeOptions, ): Candidate { let designSystem = options.designSystem - let signatures = computeUtilitySignature.get(options.signatureOptions) + let signatures = designSystem.storage[UTILITY_SIGNATURE_KEY].get(options.signatureOptions) if ( candidate.kind === 'functional' && @@ -1217,7 +1293,7 @@ function arbitraryValueToBareValueUtility( } let designSystem = options.designSystem - let signatures = computeUtilitySignature.get(options.signatureOptions) + let signatures = designSystem.storage[UTILITY_SIGNATURE_KEY].get(options.signatureOptions) let expectedSignature = signatures.get(designSystem.printCandidate(candidate)) if (expectedSignature === null) return candidate @@ -1386,7 +1462,7 @@ function modernizeArbitraryValuesVariant( ): Variant | Variant[] { let result = [variant] let designSystem = options.designSystem - let signatures = computeVariantSignature.get(options.signatureOptions) + let signatures = designSystem.storage[VARIANT_SIGNATURE_KEY] let iterator = walkVariants(variant) for (let [variant, parent] of iterator) { @@ -1751,7 +1827,7 @@ function optimizeModifier(candidate: Candidate, options: InternalCanonicalizeOpt } let designSystem = options.designSystem - let signatures = computeUtilitySignature.get(options.signatureOptions) + let signatures = designSystem.storage[UTILITY_SIGNATURE_KEY].get(options.signatureOptions) let targetSignature = signatures.get(designSystem.printCandidate(candidate)) let modifier = candidate.modifier @@ -1806,6 +1882,565 @@ function optimizeModifier(candidate: Candidate, options: InternalCanonicalizeOpt return candidate } +export enum SignatureFeatures { + None = 0, + ExpandProperties = 1 << 0, + LogicalToPhysical = 1 << 1, +} + +interface SignatureOptions { + /** + * The root font size in pixels. If provided, `rem` values will be normalized + * to `px` values. + * + * E.g.: `mt-[16px]` with `rem: 16` will become `mt-4` (assuming `--spacing: 0.25rem`). + */ + rem: number | null + + /** + * Features that influence how signatures are computed. + */ + features: SignatureFeatures +} + +// Given a utility, compute a signature that represents the utility. The +// signature will be a normalised form of the generated CSS for the utility, or +// a unique symbol if the utility is not valid. The class in the selector will +// be replaced with the `.x` selector. +// +// This function should only be passed the base utility so `flex`, `hover:flex` +// and `focus:flex` will all use just `flex`. Variants are handled separately. +// +// E.g.: +// +// | UTILITY | GENERATED SIGNATURE | +// | ---------------- | ----------------------- | +// | `[display:flex]` | `.x { display: flex; }` | +// | `flex` | `.x { display: flex; }` | +// +// These produce the same signature, therefore they represent the same utility. +export const UTILITY_SIGNATURE_KEY = Symbol() +function createUtilitySignatureCache( + designSystem: DesignSystem, +): DesignSystem['storage'][typeof UTILITY_SIGNATURE_KEY] { + return new DefaultMap((options: SignatureOptions) => { + return new DefaultMap((utility) => { + try { + // Ensure the prefix is added to the utility if it is not already present. + utility = + designSystem.theme.prefix && !utility.startsWith(designSystem.theme.prefix) + ? `${designSystem.theme.prefix}:${utility}` + : utility + + // Use `@apply` to normalize the selector to `.x` + let ast: AstNode[] = [styleRule('.x', [atRule('@apply', utility)])] + + temporarilyDisableThemeInline(designSystem, () => { + // There's separate utility caches for respect important vs not + // so we want to compile them both with `@theme inline` disabled + for (let candidate of designSystem.parseCandidate(utility)) { + designSystem.compileAstNodes(candidate, CompileAstFlags.RespectImportant) + } + + substituteAtApply(ast, designSystem) + }) + + // Optimize the AST. This is needed such that any internal intermediate + // nodes are gone. This will also cleanup declaration nodes with undefined + // values or `--tw-sort` declarations. + canonicalizeAst(designSystem, ast, options) + + // Compute the final signature, by generating the CSS for the utility + let signature = toCss(ast) + return signature + } catch { + // A unique symbol is returned to ensure that 2 signatures resulting in + // `null` are not considered equal. + return Symbol() + } + }) + }) +} + +// Optimize the CSS AST to make it suitable for signature comparison. We want to +// expand declarations, ignore comments, sort declarations etc... +function canonicalizeAst(designSystem: DesignSystem, ast: AstNode[], options: SignatureOptions) { + let { rem } = options + + walk(ast, { + enter(node, ctx) { + // Optimize declarations + if (node.kind === 'declaration') { + if (node.value === undefined || node.property === '--tw-sort') { + return WalkAction.Replace([]) + } + + // Ignore `--tw-{property}` if `{property}` exists with the same value + if (node.property.startsWith('--tw-')) { + if ( + (ctx.parent?.nodes ?? []).some( + (sibling) => + sibling.kind === 'declaration' && + node.value === sibling.value && + node.important === sibling.important && + !sibling.property.startsWith('--tw-'), + ) + ) { + return WalkAction.Replace([]) + } + } + + if (options.features & SignatureFeatures.ExpandProperties) { + let replacement = expandDeclaration(node, options.features) + if (replacement) return WalkAction.Replace(replacement) + } + + // Resolve theme values to their inlined value. + if (node.value.includes('var(')) { + node.value = resolveVariablesInValue(node.value, designSystem) + } + + // Very basic `calc(…)` constant folding to handle the spacing scale + // multiplier: + // + // Input: `--spacing(4)` + // → `calc(var(--spacing, 0.25rem) * 4)` + // → `calc(0.25rem * 4)` ← this is the case we will see + // after inlining the variable + // → `1rem` + node.value = constantFoldDeclaration(node.value, rem) + + // We will normalize the `node.value`, this is the same kind of logic + // we use when printing arbitrary values. It will remove unnecessary + // whitespace. + // + // Essentially normalizing the `node.value` to a canonical form. + node.value = printArbitraryValue(node.value) + } + + // Replace special nodes with its children + else if (node.kind === 'context' || node.kind === 'at-root') { + return WalkAction.Replace(node.nodes) + } + + // Remove comments + else if (node.kind === 'comment') { + return WalkAction.Replace([]) + } + + // Remove at-rules that are not needed for the signature + else if (node.kind === 'at-rule' && node.name === '@property') { + return WalkAction.Replace([]) + } + }, + exit(node) { + if (node.kind === 'rule' || node.kind === 'at-rule') { + node.nodes.sort((a, b) => { + if (a.kind !== 'declaration') return 0 + if (b.kind !== 'declaration') return 0 + return a.property.localeCompare(b.property) + }) + } + }, + }) + + return ast +} + +// Resolve theme values to their inlined value. +// +// E.g.: +// +// `[color:var(--color-red-500)]` → `[color:oklch(63.7%_0.237_25.331)]` +// `[color:oklch(63.7%_0.237_25.331)]` → `[color:oklch(63.7%_0.237_25.331)]` +// +// Due to the `@apply` from above, this will become: +// +// ```css +// .example { +// color: oklch(63.7% 0.237 25.331); +// } +// ``` +// +// Which conveniently will be equivalent to: `text-red-500` when we inline +// the value. +// +// Without inlining: +// ```css +// .example { +// color: var(--color-red-500, oklch(63.7% 0.237 25.331)); +// } +// ``` +// +// Inlined: +// ```css +// .example { +// color: oklch(63.7% 0.237 25.331); +// } +// ``` +// +// Recently we made sure that utilities like `text-red-500` also generate +// the fallback value for usage in `@reference` mode. +// +// The second assumption is that if you use `var(--key, fallback)` that +// happens to match a known variable _and_ its inlined value. Then we can +// replace it with the inlined variable. This allows us to handle custom +// `@theme` and `@theme inline` definitions. +function resolveVariablesInValue(value: string, designSystem: DesignSystem): string { + let changed = false + let valueAst = ValueParser.parse(value) + + let seen = new Set() + walk(valueAst, (valueNode) => { + if (valueNode.kind !== 'function') return + if (valueNode.value !== 'var') return + + // Resolve the underlying value of the variable + if (valueNode.nodes.length !== 1 && valueNode.nodes.length < 3) { + return + } + + let variable = valueNode.nodes[0].value + + // Drop the prefix from the variable name if it is present. The + // internal variable doesn't have the prefix. + if (designSystem.theme.prefix && variable.startsWith(`--${designSystem.theme.prefix}-`)) { + variable = variable.slice(`--${designSystem.theme.prefix}-`.length) + } + let variableValue = designSystem.resolveThemeValue(variable) + // Prevent infinite recursion when the variable value contains the + // variable itself. + if (seen.has(variable)) return + seen.add(variable) + if (variableValue === undefined) return // Couldn't resolve the variable + + // Inject variable fallbacks when no fallback is present yet. + // + // A fallback could consist of multiple values. + // + // E.g.: + // + // ``` + // var(--font-sans, ui-sans-serif, system-ui, sans-serif, …) + // ``` + { + // More than 1 argument means that a fallback is already present + if (valueNode.nodes.length === 1) { + // Inject the fallback value into the variable lookup + changed = true + valueNode.nodes.push(...ValueParser.parse(`,${variableValue}`)) + } + } + + // Replace known variable + inlined fallback value with the value + // itself again + { + // We need at least 3 arguments. The variable, the separator and a fallback value. + if (valueNode.nodes.length >= 3) { + let nodeAsString = ValueParser.toCss(valueNode.nodes) // This could include more than just the variable + let constructedValue = `${valueNode.nodes[0].value},${variableValue}` + if (nodeAsString === constructedValue) { + changed = true + return WalkAction.Replace(ValueParser.parse(variableValue)) + } + } + } + }) + + // Replace the value with the new value + if (changed) return ValueParser.toCss(valueAst) + return value +} + +// Index all static utilities by property and value +const STATIC_UTILITIES_KEY = Symbol() +function createStaticUtilitiesCache(): DesignSystem['storage'][typeof STATIC_UTILITIES_KEY] { + return new DefaultMap((_optiones: SignatureOptions) => { + return new DefaultMap((_property: string) => { + return new DefaultMap((_value: string) => { + return new Set() + }) + }) + }) +} + +const UTILITY_PROPERTIES_KEY = Symbol() +function createUtilityPropertiesCache( + designSystem: DesignSystem, +): DesignSystem['storage'][typeof UTILITY_PROPERTIES_KEY] { + return new DefaultMap((options: SignatureOptions) => { + return new DefaultMap((className) => { + let localPropertyValueLookup = new DefaultMap((_property) => new Set()) + + if (designSystem.theme.prefix && !className.startsWith(designSystem.theme.prefix)) { + className = `${designSystem.theme.prefix}:${className}` + } + let parsed = designSystem.parseCandidate(className) + if (parsed.length === 0) return localPropertyValueLookup + + walk( + canonicalizeAst( + designSystem, + designSystem.compileAstNodes(parsed[0]).map((x) => cloneAstNode(x.node)), + options, + ), + (node) => { + if (node.kind === 'declaration') { + localPropertyValueLookup.get(node.property).add(node.value!) + designSystem.storage[STATIC_UTILITIES_KEY].get(options) + .get(node.property) + .get(node.value!) + .add(className) + } + }, + ) + + return localPropertyValueLookup + }) + }) +} + +// For all static utilities in the system, compute a lookup table that maps the +// utility signature to the utility name. This is used to find the utility name +// for a given utility signature. +// +// For all functional utilities, we can compute static-like utilities by +// essentially pre-computing the values and modifiers. This is a bit slow, but +// also only has to happen once per design system. +const PRE_COMPUTED_UTILITIES_KEY = Symbol() +function createPreComputedUtilitiesCache( + designSystem: DesignSystem, +): DesignSystem['storage'][typeof PRE_COMPUTED_UTILITIES_KEY] { + return new DefaultMap((options: SignatureOptions) => { + let signatures = designSystem.storage[UTILITY_SIGNATURE_KEY].get(options) + let lookup = new DefaultMap(() => []) + + // Right now all plugins are implemented using functions so they are a black + // box. Let's use the `getClassList` and consider every known suggestion as a + // static utility for now. + for (let [className, meta] of designSystem.getClassList()) { + let signature = signatures.get(className) + if (typeof signature !== 'string') continue + + // Skip the utility if `-{utility}-0` has the same signature as + // `{utility}-0` (its positive version). This will prefer positive values + // over negative values. + if (className[0] === '-' && className.endsWith('-0')) { + let positiveSignature = signatures.get(className.slice(1)) + if (typeof positiveSignature === 'string' && signature === positiveSignature) { + continue + } + } + + lookup.get(signature).push(className) + designSystem.storage[UTILITY_PROPERTIES_KEY].get(options).get(className) + + for (let modifier of meta.modifiers) { + // Modifiers representing numbers can be computed and don't need to be + // pre-computed. Doing the math and at the time of writing this, this + // would save you 250k additionally pre-computed utilities... + if (isValidSpacingMultiplier(modifier)) { + continue + } + + let classNameWithModifier = `${className}/${modifier}` + let signature = signatures.get(classNameWithModifier) + if (typeof signature !== 'string') continue + lookup.get(signature).push(classNameWithModifier) + designSystem.storage[UTILITY_PROPERTIES_KEY].get(options).get(classNameWithModifier) + } + } + + return lookup + }) +} + +// Given a variant, compute a signature that represents the variant. The +// signature will be a normalised form of the generated CSS for the variant, or +// a unique symbol if the variant is not valid. The class in the selector will +// be replaced with `.x`. +// +// E.g.: +// +// | VARIANT | GENERATED SIGNATURE | +// | ---------------- | ----------------------------- | +// | `[&:focus]:flex` | `.x:focus { display: flex; }` | +// | `focus:flex` | `.x:focus { display: flex; }` | +// +// These produce the same signature, therefore they represent the same variant. +export const VARIANT_SIGNATURE_KEY = Symbol() +function createVariantSignatureCache( + designSystem: DesignSystem, +): DesignSystem['storage'][typeof VARIANT_SIGNATURE_KEY] { + return new DefaultMap((variant) => { + try { + // Ensure the prefix is added to the utility if it is not already present. + variant = + designSystem.theme.prefix && !variant.startsWith(designSystem.theme.prefix) + ? `${designSystem.theme.prefix}:${variant}` + : variant + + // Use `@apply` to normalize the selector to `.x` + let ast: AstNode[] = [styleRule('.x', [atRule('@apply', `${variant}:flex`)])] + substituteAtApply(ast, designSystem) + + // Canonicalize selectors to their minimal form + walk(ast, (node) => { + // At-rules + if (node.kind === 'at-rule' && node.params.includes(' ')) { + node.params = node.params.replaceAll(' ', '') + } + + // Style rules + else if (node.kind === 'rule') { + let selectorAst = SelectorParser.parse(node.selector) + let changed = false + walk(selectorAst, (node) => { + if (node.kind === 'separator' && node.value !== ' ') { + node.value = node.value.trim() + changed = true + } + + // Remove unnecessary `:is(…)` selectors + else if (node.kind === 'function' && node.value === ':is') { + // A single selector inside of `:is(…)` can be replaced with the + // selector itself. + // + // E.g.: `:is(.foo)` → `.foo` + if (node.nodes.length === 1) { + changed = true + return WalkAction.Replace(node.nodes) + } + + // A selector with the universal selector `*` followed by a pseudo + // class, can be replaced with the pseudo class itself. + else if ( + node.nodes.length === 2 && + node.nodes[0].kind === 'selector' && + node.nodes[0].value === '*' && + node.nodes[1].kind === 'selector' && + node.nodes[1].value[0] === ':' + ) { + changed = true + return WalkAction.Replace(node.nodes[1]) + } + } + + // Ensure `*` exists before pseudo selectors inside of `:not(…)`, + // `:where(…)`, … + // + // E.g.: + // + // `:not(:first-child)` → `:not(*:first-child)` + // + else if ( + node.kind === 'function' && + node.value[0] === ':' && + node.nodes[0]?.kind === 'selector' && + node.nodes[0]?.value[0] === ':' + ) { + changed = true + node.nodes.unshift({ kind: 'selector', value: '*' }) + } + }) + + if (changed) { + node.selector = SelectorParser.toCss(selectorAst) + } + } + }) + + // Compute the final signature, by generating the CSS for the variant + let signature = toCss(ast) + return signature + } catch { + // A unique symbol is returned to ensure that 2 signatures resulting in + // `null` are not considered equal. + return Symbol() + } + }) +} + +export const PRE_COMPUTED_VARIANTS_KEY = Symbol() +function createPreComputedVariantsCache( + designSystem: DesignSystem, +): DesignSystem['storage'][typeof PRE_COMPUTED_VARIANTS_KEY] { + let signatures = designSystem.storage[VARIANT_SIGNATURE_KEY] + let lookup = new DefaultMap(() => []) + + // Actual static variants + for (let [root, variant] of designSystem.variants.entries()) { + if (variant.kind === 'static') { + let signature = signatures.get(root) + if (typeof signature !== 'string') continue + lookup.get(signature).push(root) + } + } + + return lookup +} + +function temporarilyDisableThemeInline(designSystem: DesignSystem, cb: () => T): T { + // Turn off `@theme inline` feature such that `@theme` and `@theme inline` are + // considered the same. The biggest motivation for this is referencing + // variables in another namespace that happen to contain the same value as the + // utility's own namespaces it is reading from. + // + // E.g.: + // + // The `max-w-*` utility doesn't read from the `--breakpoint-*` namespace. + // But it does read from the `--container-*` namespace. It also happens to + // be the case that `--breakpoint-md` and `--container-3xl` are the exact + // same value. + // + // If you then use the `max-w-(--breakpoint-md)` utility, inlining the + // variable would mean: + // - `max-w-(--breakpoint-md)` → `max-width: 48rem;` → `max-w-3xl` + // - `max-w-(--contianer-3xl)` → `max-width: 48rem;` → `max-w-3xl` + // + // Not inlining the variable would mean: + // - `max-w-(--breakpoint-md)` → `max-width: var(--breakpoint-md);` → `max-w-(--breakpoint-md)` + // - `max-w-(--container-3xl)` → `max-width: var(--container-3xl);` → `max-w-3xl` + + // @ts-expect-error We are monkey-patching a method that's considered private + // in TypeScript + let originalGet = designSystem.theme.values.get + + // Track all values with the inline option set, so we can restore them later. + let restorableInlineOptions = new Set<{ options: ThemeOptions }>() + + // @ts-expect-error We are monkey-patching a method that's considered private + // in TypeScript + designSystem.theme.values.get = (key: string) => { + // @ts-expect-error We are monkey-patching a method that's considered private + // in TypeScript + let value = originalGet.call(designSystem.theme.values, key) + if (value === undefined) return value + + // Remove `inline` if it was set + if (value.options & ThemeOptions.INLINE) { + restorableInlineOptions.add(value) + value.options &= ~ThemeOptions.INLINE + } + + return value + } + + try { + // Run the callback with the `@theme inline` feature disabled + return cb() + } finally { + // Restore the `@theme inline` to the original value + // @ts-expect-error We are monkey-patching a method that's private + designSystem.theme.values.get = originalGet + + // Re-add the `inline` option, in case future lookups are done + for (let value of restorableInlineOptions) { + value.options |= ThemeOptions.INLINE + } + } +} + // Generator that generates all combinations of the given set. Using a generator // so we can stop early when we found a suitable combination. // diff --git a/packages/tailwindcss/src/design-system.ts b/packages/tailwindcss/src/design-system.ts index 2dfa24128..d533743c1 100644 --- a/packages/tailwindcss/src/design-system.ts +++ b/packages/tailwindcss/src/design-system.ts @@ -59,6 +59,9 @@ export type DesignSystem = { // Used by IntelliSense candidatesToCss(classes: string[]): (string | null)[] + + // General purpose storage + storage: Record } export function buildDesignSystem(theme: Theme): DesignSystem { @@ -214,6 +217,10 @@ export function buildDesignSystem(theme: Theme): DesignSystem { canonicalizeCandidates(candidates: string[], options?: CanonicalizeOptions) { return canonicalizeCandidates(this, candidates, options) }, + + // General purpose storage, each key has to be a unique symbol to avoid + // collisions. + storage: {}, } return designSystem diff --git a/packages/tailwindcss/src/expand-declaration.test.ts b/packages/tailwindcss/src/expand-declaration.test.ts index 44456f239..1f9e71561 100644 --- a/packages/tailwindcss/src/expand-declaration.test.ts +++ b/packages/tailwindcss/src/expand-declaration.test.ts @@ -1,8 +1,8 @@ import { describe, expect, test } from 'vitest' import { toCss } from './ast' +import { SignatureFeatures } from './canonicalize-candidates' import { parse } from './css-parser' import { expandDeclaration } from './expand-declaration' -import { SignatureFeatures } from './signatures' import { walk, WalkAction } from './walk' const css = String.raw diff --git a/packages/tailwindcss/src/expand-declaration.ts b/packages/tailwindcss/src/expand-declaration.ts index 1dc5df6f2..628848ab4 100644 --- a/packages/tailwindcss/src/expand-declaration.ts +++ b/packages/tailwindcss/src/expand-declaration.ts @@ -1,5 +1,5 @@ import { decl, type AstNode } from './ast' -import { SignatureFeatures } from './signatures' +import { SignatureFeatures } from './canonicalize-candidates' import { segment } from './utils/segment' function createPrefixedQuad( diff --git a/packages/tailwindcss/src/signatures.ts b/packages/tailwindcss/src/signatures.ts deleted file mode 100644 index 7ad608f22..000000000 --- a/packages/tailwindcss/src/signatures.ts +++ /dev/null @@ -1,561 +0,0 @@ -import { substituteAtApply } from './apply' -import { atRule, cloneAstNode, styleRule, toCss, type AstNode } from './ast' -import { printArbitraryValue } from './candidate' -import { constantFoldDeclaration } from './constant-fold-declaration' -import { CompileAstFlags, type DesignSystem } from './design-system' -import { expandDeclaration } from './expand-declaration' -import * as SelectorParser from './selector-parser' -import { ThemeOptions } from './theme' -import { DefaultMap } from './utils/default-map' -import { isValidSpacingMultiplier } from './utils/infer-data-type' -import * as ValueParser from './value-parser' -import { walk, WalkAction } from './walk' - -export enum SignatureFeatures { - None = 0, - ExpandProperties = 1 << 0, - LogicalToPhysical = 1 << 1, -} - -export interface SignatureOptions { - /** - * The root font size in pixels. If provided, `rem` values will be normalized - * to `px` values. - * - * E.g.: `mt-[16px]` with `rem: 16` will become `mt-4` (assuming `--spacing: 0.25rem`). - */ - rem: number | null - - /** - * Features that influence how signatures are computed. - */ - features: SignatureFeatures - - /** - * The design system to use for computing the signature of candidates. - */ - designSystem: DesignSystem -} - -// Given a utility, compute a signature that represents the utility. The -// signature will be a normalised form of the generated CSS for the utility, or -// a unique symbol if the utility is not valid. The class in the selector will -// be replaced with the `.x` selector. -// -// This function should only be passed the base utility so `flex`, `hover:flex` -// and `focus:flex` will all use just `flex`. Variants are handled separately. -// -// E.g.: -// -// | UTILITY | GENERATED SIGNATURE | -// | ---------------- | ----------------------- | -// | `[display:flex]` | `.x { display: flex; }` | -// | `flex` | `.x { display: flex; }` | -// -// These produce the same signature, therefore they represent the same utility. -export const computeUtilitySignature = new DefaultMap((options: SignatureOptions) => { - let designSystem = options.designSystem - - return new DefaultMap((utility) => { - try { - // Ensure the prefix is added to the utility if it is not already present. - utility = - designSystem.theme.prefix && !utility.startsWith(designSystem.theme.prefix) - ? `${designSystem.theme.prefix}:${utility}` - : utility - - // Use `@apply` to normalize the selector to `.x` - let ast: AstNode[] = [styleRule('.x', [atRule('@apply', utility)])] - - temporarilyDisableThemeInline(designSystem, () => { - // There's separate utility caches for respect important vs not - // so we want to compile them both with `@theme inline` disabled - for (let candidate of designSystem.parseCandidate(utility)) { - designSystem.compileAstNodes(candidate, CompileAstFlags.RespectImportant) - } - - substituteAtApply(ast, designSystem) - }) - - // Optimize the AST. This is needed such that any internal intermediate - // nodes are gone. This will also cleanup declaration nodes with undefined - // values or `--tw-sort` declarations. - canonicalizeAst(ast, options) - - // Compute the final signature, by generating the CSS for the utility - let signature = toCss(ast) - return signature - } catch { - // A unique symbol is returned to ensure that 2 signatures resulting in - // `null` are not considered equal. - return Symbol() - } - }) -}) - -// Optimize the CSS AST to make it suitable for signature comparison. We want to -// expand declarations, ignore comments, sort declarations etc... -function canonicalizeAst(ast: AstNode[], options: SignatureOptions) { - let { rem, designSystem } = options - - walk(ast, { - enter(node, ctx) { - // Optimize declarations - if (node.kind === 'declaration') { - if (node.value === undefined || node.property === '--tw-sort') { - return WalkAction.Replace([]) - } - - // Ignore `--tw-{property}` if `{property}` exists with the same value - if (node.property.startsWith('--tw-')) { - if ( - (ctx.parent?.nodes ?? []).some( - (sibling) => - sibling.kind === 'declaration' && - node.value === sibling.value && - node.important === sibling.important && - !sibling.property.startsWith('--tw-'), - ) - ) { - return WalkAction.Replace([]) - } - } - - if (options.features & SignatureFeatures.ExpandProperties) { - let replacement = expandDeclaration(node, options.features) - if (replacement) return WalkAction.Replace(replacement) - } - - // Resolve theme values to their inlined value. - if (node.value.includes('var(')) { - node.value = resolveVariablesInValue(node.value, designSystem) - } - - // Very basic `calc(…)` constant folding to handle the spacing scale - // multiplier: - // - // Input: `--spacing(4)` - // → `calc(var(--spacing, 0.25rem) * 4)` - // → `calc(0.25rem * 4)` ← this is the case we will see - // after inlining the variable - // → `1rem` - node.value = constantFoldDeclaration(node.value, rem) - - // We will normalize the `node.value`, this is the same kind of logic - // we use when printing arbitrary values. It will remove unnecessary - // whitespace. - // - // Essentially normalizing the `node.value` to a canonical form. - node.value = printArbitraryValue(node.value) - } - - // Replace special nodes with its children - else if (node.kind === 'context' || node.kind === 'at-root') { - return WalkAction.Replace(node.nodes) - } - - // Remove comments - else if (node.kind === 'comment') { - return WalkAction.Replace([]) - } - - // Remove at-rules that are not needed for the signature - else if (node.kind === 'at-rule' && node.name === '@property') { - return WalkAction.Replace([]) - } - }, - exit(node) { - if (node.kind === 'rule' || node.kind === 'at-rule') { - node.nodes.sort((a, b) => { - if (a.kind !== 'declaration') return 0 - if (b.kind !== 'declaration') return 0 - return a.property.localeCompare(b.property) - }) - } - }, - }) - - return ast -} - -// Resolve theme values to their inlined value. -// -// E.g.: -// -// `[color:var(--color-red-500)]` → `[color:oklch(63.7%_0.237_25.331)]` -// `[color:oklch(63.7%_0.237_25.331)]` → `[color:oklch(63.7%_0.237_25.331)]` -// -// Due to the `@apply` from above, this will become: -// -// ```css -// .example { -// color: oklch(63.7% 0.237 25.331); -// } -// ``` -// -// Which conveniently will be equivalent to: `text-red-500` when we inline -// the value. -// -// Without inlining: -// ```css -// .example { -// color: var(--color-red-500, oklch(63.7% 0.237 25.331)); -// } -// ``` -// -// Inlined: -// ```css -// .example { -// color: oklch(63.7% 0.237 25.331); -// } -// ``` -// -// Recently we made sure that utilities like `text-red-500` also generate -// the fallback value for usage in `@reference` mode. -// -// The second assumption is that if you use `var(--key, fallback)` that -// happens to match a known variable _and_ its inlined value. Then we can -// replace it with the inlined variable. This allows us to handle custom -// `@theme` and `@theme inline` definitions. -function resolveVariablesInValue(value: string, designSystem: DesignSystem): string { - let changed = false - let valueAst = ValueParser.parse(value) - - let seen = new Set() - walk(valueAst, (valueNode) => { - if (valueNode.kind !== 'function') return - if (valueNode.value !== 'var') return - - // Resolve the underlying value of the variable - if (valueNode.nodes.length !== 1 && valueNode.nodes.length < 3) { - return - } - - let variable = valueNode.nodes[0].value - - // Drop the prefix from the variable name if it is present. The - // internal variable doesn't have the prefix. - if (designSystem.theme.prefix && variable.startsWith(`--${designSystem.theme.prefix}-`)) { - variable = variable.slice(`--${designSystem.theme.prefix}-`.length) - } - let variableValue = designSystem.resolveThemeValue(variable) - // Prevent infinite recursion when the variable value contains the - // variable itself. - if (seen.has(variable)) return - seen.add(variable) - if (variableValue === undefined) return // Couldn't resolve the variable - - // Inject variable fallbacks when no fallback is present yet. - // - // A fallback could consist of multiple values. - // - // E.g.: - // - // ``` - // var(--font-sans, ui-sans-serif, system-ui, sans-serif, …) - // ``` - { - // More than 1 argument means that a fallback is already present - if (valueNode.nodes.length === 1) { - // Inject the fallback value into the variable lookup - changed = true - valueNode.nodes.push(...ValueParser.parse(`,${variableValue}`)) - } - } - - // Replace known variable + inlined fallback value with the value - // itself again - { - // We need at least 3 arguments. The variable, the separator and a fallback value. - if (valueNode.nodes.length >= 3) { - let nodeAsString = ValueParser.toCss(valueNode.nodes) // This could include more than just the variable - let constructedValue = `${valueNode.nodes[0].value},${variableValue}` - if (nodeAsString === constructedValue) { - changed = true - return WalkAction.Replace(ValueParser.parse(variableValue)) - } - } - } - }) - - // Replace the value with the new value - if (changed) return ValueParser.toCss(valueAst) - return value -} - -// Index all static utilities by property and value -export const staticUtilitiesByPropertyAndValue = new DefaultMap((_optiones: SignatureOptions) => { - return new DefaultMap((_property: string) => { - return new DefaultMap((_value: string) => { - return new Set() - }) - }) -}) - -export const computeUtilityProperties = new DefaultMap((options: SignatureOptions) => { - return new DefaultMap((className) => { - let localPropertyValueLookup = new DefaultMap((_property) => new Set()) - let designSystem = options.designSystem - - if ( - options.designSystem.theme.prefix && - !className.startsWith(options.designSystem.theme.prefix) - ) { - className = `${options.designSystem.theme.prefix}:${className}` - } - let parsed = designSystem.parseCandidate(className) - if (parsed.length === 0) return localPropertyValueLookup - - walk( - canonicalizeAst( - designSystem.compileAstNodes(parsed[0]).map((x) => cloneAstNode(x.node)), - options, - ), - (node) => { - if (node.kind === 'declaration') { - localPropertyValueLookup.get(node.property).add(node.value!) - staticUtilitiesByPropertyAndValue - .get(options) - .get(node.property) - .get(node.value!) - .add(className) - } - }, - ) - - return localPropertyValueLookup - }) -}) - -// For all static utilities in the system, compute a lookup table that maps the -// utility signature to the utility name. This is used to find the utility name -// for a given utility signature. -// -// For all functional utilities, we can compute static-like utilities by -// essentially pre-computing the values and modifiers. This is a bit slow, but -// also only has to happen once per design system. -export const preComputedUtilities = new DefaultMap((options: SignatureOptions) => { - let { designSystem } = options - let signatures = computeUtilitySignature.get(options) - let lookup = new DefaultMap(() => []) - - // Right now all plugins are implemented using functions so they are a black - // box. Let's use the `getClassList` and consider every known suggestion as a - // static utility for now. - for (let [className, meta] of designSystem.getClassList()) { - let signature = signatures.get(className) - if (typeof signature !== 'string') continue - - // Skip the utility if `-{utility}-0` has the same signature as - // `{utility}-0` (its positive version). This will prefer positive values - // over negative values. - if (className[0] === '-' && className.endsWith('-0')) { - let positiveSignature = signatures.get(className.slice(1)) - if (typeof positiveSignature === 'string' && signature === positiveSignature) { - continue - } - } - - lookup.get(signature).push(className) - computeUtilityProperties.get(options).get(className) - - for (let modifier of meta.modifiers) { - // Modifiers representing numbers can be computed and don't need to be - // pre-computed. Doing the math and at the time of writing this, this - // would save you 250k additionally pre-computed utilities... - if (isValidSpacingMultiplier(modifier)) { - continue - } - - let classNameWithModifier = `${className}/${modifier}` - let signature = signatures.get(classNameWithModifier) - if (typeof signature !== 'string') continue - lookup.get(signature).push(classNameWithModifier) - computeUtilityProperties.get(options).get(classNameWithModifier) - } - } - - return lookup -}) - -// Given a variant, compute a signature that represents the variant. The -// signature will be a normalised form of the generated CSS for the variant, or -// a unique symbol if the variant is not valid. The class in the selector will -// be replaced with `.x`. -// -// E.g.: -// -// | VARIANT | GENERATED SIGNATURE | -// | ---------------- | ----------------------------- | -// | `[&:focus]:flex` | `.x:focus { display: flex; }` | -// | `focus:flex` | `.x:focus { display: flex; }` | -// -// These produce the same signature, therefore they represent the same variant. -export const computeVariantSignature = new DefaultMap((options: SignatureOptions) => { - let { designSystem } = options - return new DefaultMap((variant) => { - try { - // Ensure the prefix is added to the utility if it is not already present. - variant = - designSystem.theme.prefix && !variant.startsWith(designSystem.theme.prefix) - ? `${designSystem.theme.prefix}:${variant}` - : variant - - // Use `@apply` to normalize the selector to `.x` - let ast: AstNode[] = [styleRule('.x', [atRule('@apply', `${variant}:flex`)])] - substituteAtApply(ast, designSystem) - - // Canonicalize selectors to their minimal form - walk(ast, (node) => { - // At-rules - if (node.kind === 'at-rule' && node.params.includes(' ')) { - node.params = node.params.replaceAll(' ', '') - } - - // Style rules - else if (node.kind === 'rule') { - let selectorAst = SelectorParser.parse(node.selector) - let changed = false - walk(selectorAst, (node) => { - if (node.kind === 'separator' && node.value !== ' ') { - node.value = node.value.trim() - changed = true - } - - // Remove unnecessary `:is(…)` selectors - else if (node.kind === 'function' && node.value === ':is') { - // A single selector inside of `:is(…)` can be replaced with the - // selector itself. - // - // E.g.: `:is(.foo)` → `.foo` - if (node.nodes.length === 1) { - changed = true - return WalkAction.Replace(node.nodes) - } - - // A selector with the universal selector `*` followed by a pseudo - // class, can be replaced with the pseudo class itself. - else if ( - node.nodes.length === 2 && - node.nodes[0].kind === 'selector' && - node.nodes[0].value === '*' && - node.nodes[1].kind === 'selector' && - node.nodes[1].value[0] === ':' - ) { - changed = true - return WalkAction.Replace(node.nodes[1]) - } - } - - // Ensure `*` exists before pseudo selectors inside of `:not(…)`, - // `:where(…)`, … - // - // E.g.: - // - // `:not(:first-child)` → `:not(*:first-child)` - // - else if ( - node.kind === 'function' && - node.value[0] === ':' && - node.nodes[0]?.kind === 'selector' && - node.nodes[0]?.value[0] === ':' - ) { - changed = true - node.nodes.unshift({ kind: 'selector', value: '*' }) - } - }) - - if (changed) { - node.selector = SelectorParser.toCss(selectorAst) - } - } - }) - - // Compute the final signature, by generating the CSS for the variant - let signature = toCss(ast) - return signature - } catch { - // A unique symbol is returned to ensure that 2 signatures resulting in - // `null` are not considered equal. - return Symbol() - } - }) -}) - -export const preComputedVariants = new DefaultMap((options: SignatureOptions) => { - let { designSystem } = options - let signatures = computeVariantSignature.get(options) - let lookup = new DefaultMap(() => []) - - // Actual static variants - for (let [root, variant] of designSystem.variants.entries()) { - if (variant.kind === 'static') { - let signature = signatures.get(root) - if (typeof signature !== 'string') continue - lookup.get(signature).push(root) - } - } - - return lookup -}) - -function temporarilyDisableThemeInline(designSystem: DesignSystem, cb: () => T): T { - // Turn off `@theme inline` feature such that `@theme` and `@theme inline` are - // considered the same. The biggest motivation for this is referencing - // variables in another namespace that happen to contain the same value as the - // utility's own namespaces it is reading from. - // - // E.g.: - // - // The `max-w-*` utility doesn't read from the `--breakpoint-*` namespace. - // But it does read from the `--container-*` namespace. It also happens to - // be the case that `--breakpoint-md` and `--container-3xl` are the exact - // same value. - // - // If you then use the `max-w-(--breakpoint-md)` utility, inlining the - // variable would mean: - // - `max-w-(--breakpoint-md)` → `max-width: 48rem;` → `max-w-3xl` - // - `max-w-(--contianer-3xl)` → `max-width: 48rem;` → `max-w-3xl` - // - // Not inlining the variable would mean: - // - `max-w-(--breakpoint-md)` → `max-width: var(--breakpoint-md);` → `max-w-(--breakpoint-md)` - // - `max-w-(--container-3xl)` → `max-width: var(--container-3xl);` → `max-w-3xl` - - // @ts-expect-error We are monkey-patching a method that's considered private - // in TypeScript - let originalGet = designSystem.theme.values.get - - // Track all values with the inline option set, so we can restore them later. - let restorableInlineOptions = new Set<{ options: ThemeOptions }>() - - // @ts-expect-error We are monkey-patching a method that's considered private - // in TypeScript - designSystem.theme.values.get = (key: string) => { - // @ts-expect-error We are monkey-patching a method that's considered private - // in TypeScript - let value = originalGet.call(designSystem.theme.values, key) - if (value === undefined) return value - - // Remove `inline` if it was set - if (value.options & ThemeOptions.INLINE) { - restorableInlineOptions.add(value) - value.options &= ~ThemeOptions.INLINE - } - - return value - } - - try { - // Run the callback with the `@theme inline` feature disabled - return cb() - } finally { - // Restore the `@theme inline` to the original value - // @ts-expect-error We are monkey-patching a method that's private - designSystem.theme.values.get = originalGet - - // Re-add the `inline` option, in case future lookups are done - for (let value of restorableInlineOptions) { - value.options |= ThemeOptions.INLINE - } - } -}