Skip to content

Commit 66c18ca

Browse files
authored
Collapse multiple utilities (#19147)
Some commits look like a lot of changes, but they just move things around, so best to use `?w=1` when viewing commit by commit. This PR adds a new feature to the `designSystem.canonicalizeCandidates` to collapse multiple utilities to fewer utilities. To make this possible, we also have to convert some logical properties to physical properties (controllable via an option). ```ts ds.canonicalizeCandidates(['w-4', 'h-4'], { collapse: true // Default `true` logicalToPhysical: true // Default `true` }) // → ['size-4'] ``` We can already compute the signature of each utility, where if two utilities have the same signature they are considered the same. However it's kind of impossible to generate all combinations of all utilities ever to figure out if there is a potential collapse possible. Even if we just focus on the incoming list of candidates, there could still be a lot of classes. So instead we have to be a bit more clever. First, we group candidates together by the used variants and if the important `!` flag was used. We can improve this in the future, but for now we won't even try to combine `hover:w-4 h-4`. Next, for each candidate, we figure out which property and value it uses. We can build up a lookup table for this. We already did this process for all utilities in the system as well. The lookup table for `w-4 h-4 p-4` might look something like this. ```json { "width": { "16px": ["w-4", "size-4"], }, "height": { "16px": ["h-4", "size-4"], }, "padding": { "16px": ["p-4"], } } ``` Next, we can build groups of candidates where an intersection exists in the lookup table. In the example above, we can see that `w-4` and `h-4` both map to `size-4` for the value `16px`. So we can group these two candidates. The `p-4`d doesn't intersect with anything else, so it remains alone. This also means that we only have to generate combinations for two candidates (2^2 = 4) instead of three (2^3 = 8). In practice, your class list might have many classes, so keeping this number low is important. When we generate combinations, we will generate the most amount of candidates first so we have the largest collapse possible. We also stop when we reach <= 1 combinations because we need at least two candidates to collapse. Since this uses the internal design system, if you have custom `@utility`s, this will work as expected. ```css @Utility example { @apply border rounded p-4; } ``` If you then use: ```html <div class="border m-0 rounded p-4"></div> ``` Then we can collapse this to: ```html <div class="example m-0"></div> ``` But even if we used: ```html <div class="border m-0 rounded pt-4 pb-4 px-4"></div> ``` It would still collapse to: ```html <div class="example m-0"></div> ``` ...because the `pt-4` and `pb-4` can collapse to `py-4`, and `py-4` and `px-4` can collapse to `p-4`. This is also where that logical to physical conversion comes into play, because while `pt-4` and `pb-4` set the physical `padding-top` and `padding-bottom` properties. The `py-4` utility sets the logical `padding-block` property. So we internally transform `padding-block` to the `padding-top` and `padding-bottom` physical properties. The funny thing is that this logical to physical conversion actually means that we will convert `pt-4` and `pb-4` from _physical_ to _logical_ properties, because we converted to `py-4`... In _most_ cases it's fine, and even preferred to use `py-1` over `pt-1 pb-1`, but in case it's not, then you can disable the `logicalToPhysical` option. ## Test plan Added some dedicated tests for this new functionality.
1 parent 6c40814 commit 66c18ca

File tree

5 files changed

+893
-189
lines changed

5 files changed

+893
-189
lines changed

packages/tailwindcss/src/canonicalize-candidates.test.ts

Lines changed: 63 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,12 @@ const designSystems = new DefaultMap((base: string) => {
2727
})
2828
})
2929

30+
const DEFAULT_CANONICALIZATION_OPTIONS: CanonicalizeOptions = {
31+
rem: 16,
32+
collapse: true,
33+
logicalToPhysical: true,
34+
}
35+
3036
describe.each([['default'], ['with-variant'], ['important'], ['prefix']])('%s', (strategy) => {
3137
let testName = '`%s` → `%s` (%#)'
3238
if (strategy === 'with-variant') {
@@ -68,7 +74,7 @@ describe.each([['default'], ['with-variant'], ['important'], ['prefix']])('%s',
6874
input: string,
6975
candidate: string,
7076
expected: string,
71-
options?: CanonicalizeOptions,
77+
options: CanonicalizeOptions = DEFAULT_CANONICALIZATION_OPTIONS,
7278
) {
7379
candidate = prepare(candidate)
7480
expected = prepare(expected)
@@ -88,6 +94,30 @@ describe.each([['default'], ['with-variant'], ['important'], ['prefix']])('%s',
8894
}
8995
}
9096

97+
async function expectCombinedCanonicalization(
98+
input: string,
99+
candidates: string,
100+
expected: string,
101+
options: CanonicalizeOptions = DEFAULT_CANONICALIZATION_OPTIONS,
102+
) {
103+
let preparedCandidates = candidates.split(/\s+/g).map(prepare)
104+
let preparedExpected = expected.split(/\s+/g).map(prepare)
105+
106+
if (strategy === 'prefix') {
107+
input = input.replace("@import 'tailwindcss';", "@import 'tailwindcss' prefix(tw);")
108+
}
109+
110+
let designSystem = await designSystems.get(__dirname).get(input)
111+
let actual = designSystem.canonicalizeCandidates(preparedCandidates, options)
112+
113+
try {
114+
expect(actual).toEqual(preparedExpected)
115+
} catch (err) {
116+
if (err instanceof Error) Error.captureStackTrace(err, expectCombinedCanonicalization)
117+
throw err
118+
}
119+
}
120+
91121
/// ----------------------------------
92122

93123
test.each([
@@ -269,7 +299,7 @@ describe.each([['default'], ['with-variant'], ['important'], ['prefix']])('%s',
269299
['[color:oklch(62.3%_0.214_259.815)]/50', 'text-primary'],
270300

271301
// Arbitrary property to arbitrary value
272-
['[max-height:20px]', 'max-h-[20px]'],
302+
['[max-height:20%]', 'max-h-[20%]'],
273303

274304
// Arbitrary property to bare value
275305
['[grid-column:2]', 'col-2'],
@@ -914,6 +944,37 @@ describe.each([['default'], ['with-variant'], ['important'], ['prefix']])('%s',
914944
await expectCanonicalization(input, candidate, expected)
915945
})
916946
})
947+
948+
test.each([
949+
// 4 to 1
950+
['mt-1 mr-1 mb-1 ml-1', 'm-1'],
951+
952+
// 2 to 1
953+
['mt-1 mb-1', 'my-1'],
954+
955+
// Different order as above
956+
['mb-1 mt-1', 'my-1'],
957+
958+
// To completely different utility
959+
['w-4 h-4', 'size-4'],
960+
961+
// Do not touch if not operating on the same variants
962+
['hover:w-4 h-4', 'hover:w-4 h-4'],
963+
964+
// Arbitrary properties to combined class
965+
['[width:_16px_] [height:16px]', 'size-4'],
966+
967+
// Arbitrary properties to combined class with modifier
968+
['[font-size:14px] [line-height:1.625]', 'text-sm/relaxed'],
969+
])(
970+
'should canonicalize multiple classes `%s` into a shorthand `%s`',
971+
async (candidates, expected) => {
972+
let input = css`
973+
@import 'tailwindcss';
974+
`
975+
await expectCombinedCanonicalization(input, candidates, expected)
976+
},
977+
)
917978
})
918979

919980
describe('theme to var', () => {

0 commit comments

Comments
 (0)