Skip to content

Commit 2822138

Browse files
committed
collapse properties
If there is more than 1 property and the feature flag is enabled, then we can try and collapse used utilities. To do this, we have to do some setup first: 1. Figure out what properties / values are used by each candidate 2. Figure out if 2 or more candidates can be linked to another known utility in the system based on the properties / values. This way `w-4`, `h-4` will be connected via `size-4`. But `underline` and `text-red-500` will never be linked together. 4. For each group, try each combination from high to low. E.g.: while `mt-1 mr-1 mb-1 ml-1` can be collapsed to `my-1 mx-1`, starting with the most combinations first means that we can immediately collapse this to `m-1`.
1 parent 15d2792 commit 2822138

File tree

1 file changed

+216
-0
lines changed

1 file changed

+216
-0
lines changed

packages/tailwindcss/src/canonicalize-candidates.ts

Lines changed: 216 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,10 +12,13 @@ import { keyPathToCssProperty } from './compat/apply-config-to-theme'
1212
import type { DesignSystem } from './design-system'
1313
import * as SelectorParser from './selector-parser'
1414
import {
15+
computeUtilityProperties,
1516
computeUtilitySignature,
1617
computeVariantSignature,
1718
preComputedUtilities,
1819
preComputedVariants,
20+
SignatureFeatures,
21+
staticUtilitiesByPropertyAndValue,
1922
type SignatureOptions,
2023
} from './signatures'
2124
import type { Writable } from './types'
@@ -129,7 +132,172 @@ export function canonicalizeCandidates(
129132
for (let candidate of candidates) {
130133
result.add(cache.get(candidate))
131134
}
135+
136+
return result.size <= 1 || !(canonicalizeOptions.features & Features.CollapseUtilities)
137+
? Array.from(result)
138+
: collapseCandidates(canonicalizeOptions, Array.from(result))
139+
}
140+
141+
function collapseCandidates(options: InternalCanonicalizeOptions, candidates: string[]): string[] {
142+
if (candidates.length <= 1) return candidates
143+
144+
// To keep things simple, we group candidates such that we only collapse
145+
// candidates with the same variants and important modifier together.
146+
let groups = new DefaultMap((_before: string) => {
147+
return new DefaultMap((_after: string) => {
148+
return new Set<string>()
149+
})
150+
})
151+
152+
let prefix = options.designSystem.theme.prefix ? `${options.designSystem.theme.prefix}:` : ''
153+
154+
for (let candidate of candidates) {
155+
let variants = segment(candidate, ':')
156+
let utility = variants.pop()!
157+
158+
let important = utility.endsWith('!')
159+
if (important) {
160+
utility = utility.slice(0, -1)
161+
}
162+
163+
let before = variants.length > 0 ? `${variants.join(':')}:` : ''
164+
let after = important ? '!' : ''
165+
166+
// Group by variants and important flag
167+
groups.get(before).get(after).add(`${prefix}${utility}`)
168+
}
169+
170+
let result = new Set<string>()
171+
for (let [before, group] of groups.entries()) {
172+
for (let [after, candidates] of group.entries()) {
173+
for (let candidate of collapseGroup(Array.from(candidates))) {
174+
// Drop the prefix if we had one, because the prefix is already there as
175+
// part of the variants.
176+
if (prefix && candidate.startsWith(prefix)) {
177+
candidate = candidate.slice(prefix.length)
178+
}
179+
180+
result.add(`${before}${candidate}${after}`)
181+
}
182+
}
183+
}
184+
132185
return Array.from(result)
186+
187+
function collapseGroup(candidates: string[]) {
188+
let signatureOptions = options.signatureOptions
189+
let computeUtilitiesPropertiesLookup = computeUtilityProperties.get(signatureOptions)
190+
let staticUtilities = staticUtilitiesByPropertyAndValue.get(signatureOptions)
191+
192+
// For each candidate, compute the used properties and values. E.g.: `mt-1` → `margin-top` → `0.25rem`
193+
//
194+
// NOTE: Currently assuming we are dealing with static utilities only. This
195+
// will change the moment we have `@utility` for most built-ins.
196+
let candidatePropertiesValues = candidates.map((candidate) =>
197+
computeUtilitiesPropertiesLookup.get(candidate),
198+
)
199+
200+
// For each property, lookup other utilities that also set this property and
201+
// this exact value. If multiple properties are used, use the intersection of
202+
// each property.
203+
//
204+
// E.g.: `margin-top` → `mt-1`, `my-1`, `m-1`
205+
let otherUtilities = candidatePropertiesValues.map((propertyValues) => {
206+
let result: Set<string> | null = null
207+
for (let [property, values] of propertyValues) {
208+
for (let value of values) {
209+
let otherUtilities = staticUtilities.get(property).get(value)
210+
211+
if (result === null) result = new Set(otherUtilities)
212+
else result = intersection(result, otherUtilities)
213+
214+
// The moment no other utilities match, we can stop searching because
215+
// all intersections with an empty set will remain empty.
216+
if (result!.size === 0) return result!
217+
}
218+
}
219+
return result!
220+
})
221+
222+
// Link each candidate that could be linked via another utility
223+
// (intersection). This way we can reduce the amount of required combinations.
224+
//
225+
// E.g.: `mt-1` and `mb-1` can be linked via `my-1`.
226+
//
227+
// Candidates that cannot be linked won't be able to be collapsed.
228+
// E.g.: `mt-1` and `text-red-500` cannot be collapsed because there is no 3rd
229+
// utility with overlapping property/value combinations.
230+
let linked = new DefaultMap<number, Set<number>>((key) => new Set<number>([key]))
231+
let otherUtilitiesArray = Array.from(otherUtilities)
232+
for (let i = 0; i < otherUtilitiesArray.length; i++) {
233+
let current = otherUtilitiesArray[i]
234+
for (let j = i + 1; j < otherUtilitiesArray.length; j++) {
235+
let other = otherUtilitiesArray[j]
236+
237+
for (let property of current) {
238+
if (other.has(property)) {
239+
linked.get(i).add(j)
240+
linked.get(j).add(i)
241+
242+
// The moment we find a link, we can stop comparing and move on to the
243+
// next candidate. This will safe us some time
244+
break
245+
}
246+
}
247+
}
248+
}
249+
250+
// Not a single candidate can be linked to another one, nothing left to do
251+
if (linked.size === 0) return candidates
252+
253+
// Each candidate index will now have a set of other candidate indexes as
254+
// its value. Let's make the lists unique combinations so that we can
255+
// iterate over them.
256+
let uniqueCombinations = new DefaultMap((key: string) => key.split(',').map(Number))
257+
for (let group of linked.values()) {
258+
let sorted = Array.from(group).sort((a, b) => a - b)
259+
uniqueCombinations.get(sorted.join(','))
260+
}
261+
262+
// Let's try to actually collapse them now.
263+
let result = new Set<string>(candidates)
264+
let drop = new Set<string>()
265+
266+
for (let idxs of uniqueCombinations.values()) {
267+
for (let combo of combinations(idxs)) {
268+
if (combo.some((idx) => drop.has(candidates[idx]))) continue // Skip already dropped items
269+
270+
let potentialReplacements = combo.flatMap((idx) => otherUtilities[idx]).reduce(intersection)
271+
272+
let collapsedSignature = computeUtilitySignature.get(signatureOptions).get(
273+
combo
274+
.map((idx) => candidates[idx])
275+
.sort((a, z) => a.localeCompare(z)) // Sort to increase cache hits
276+
.join(' '),
277+
)
278+
279+
for (let replacement of potentialReplacements) {
280+
let signature = computeUtilitySignature.get(signatureOptions).get(replacement)
281+
if (signature !== collapsedSignature) continue // Not a safe replacement
282+
283+
// We can replace all items in the combo with the replacement
284+
for (let item of combo) {
285+
drop.add(candidates[item])
286+
}
287+
288+
// Use the replacement
289+
result.add(replacement)
290+
break
291+
}
292+
}
293+
}
294+
295+
for (let item of drop) {
296+
result.delete(item)
297+
}
298+
299+
return Array.from(result)
300+
}
133301
}
134302

135303
const canonicalizeCandidateCache = new DefaultMap((options: InternalCanonicalizeOptions) => {
@@ -1634,3 +1802,51 @@ function optimizeModifier(candidate: Candidate, options: InternalCanonicalizeOpt
16341802

16351803
return candidate
16361804
}
1805+
1806+
// Generator that generates all combinations of the given set. Using a generator
1807+
// so we can stop early when we found a suitable combination.
1808+
//
1809+
// NOTE:
1810+
//
1811+
// 1. Yield biggest combinations first
1812+
// 2. Sets of size 1 and 0 are not yielded
1813+
function* combinations<T>(arr: T[]): Generator<T[]> {
1814+
let n = arr.length
1815+
1816+
for (let k = n; k >= 2; k--) {
1817+
let mask = (1n << BigInt(k)) - 1n
1818+
let limit = 1n << BigInt(n)
1819+
1820+
while (mask < limit) {
1821+
let out = new Array<T>(k)
1822+
let p = 0
1823+
for (let i = 0; i < n; i++) {
1824+
if ((mask >> BigInt(i)) & 1n) {
1825+
out[p++] = arr[i]
1826+
}
1827+
}
1828+
yield out
1829+
1830+
let carry = mask & -mask
1831+
let ripple = mask + carry
1832+
mask = (((ripple ^ mask) >> 2n) / carry) | ripple
1833+
}
1834+
}
1835+
}
1836+
1837+
function intersection<T>(a: Set<T>, b: Set<T>): Set<T> {
1838+
// @ts-expect-error Set.prototype.intersection is only available in Node.js v22+
1839+
if (typeof a.intersection === 'function') return a.intersection(b)
1840+
1841+
// Polyfill for environments that do not support Set.prototype.intersection yet
1842+
if (a.size === 0 || b.size === 0) return new Set<T>()
1843+
1844+
let result = new Set<T>(a)
1845+
for (let item of b) {
1846+
if (!result.has(item)) {
1847+
result.delete(item)
1848+
}
1849+
}
1850+
1851+
return result
1852+
}

0 commit comments

Comments
 (0)