Skip to content

Commit ebaff18

Browse files
Fix stacking variant order when variants inside a group are treated as equal (#14431)
This PR fixes an issue with the order of CSS when using stacked variants when two variants have the same order (as defined by the custom comperator function). ## The problem Take, for example, our breakpoint variants. Those are split into `max-*` variants and a group containing all `min-*` variants as well as the unprefixed static ones (e.g. `lg`, `sm`). We currently define a custom sort order for all breakpoints variants that will compare their order based on the resolved value provided. So if you define `--breakpoint-sm: 100px` and `--breakpoint-lg: 200px`, we first check if both breakpoints have the same unit and then we rank based on the numerical value, making `sm` appear before `lg`. But since the `min-*` variant and the `sm` variant share the same group, this also means that `min-sm` and `sm` as well as `min-lg` and `lg` will always have the same order (which makes sense—they also have the exact same CSS they generate!) The issue now arises when you use these together with variant stacking. So, say you want to stack the two variants `max-lg:min-sm`. We always want stacked variants to appear _after_ their non-stacked individual parts (since they are more specific). To do this right now, we generate a bitfield based on the variant order. If you have four variants like this: | Order | Variant | | ------------- | ------------- | | 0 | `max-lg` | | 1 | `max-sm` | | 2 | `min-sm` | | 3 | `min-lg` | We will assign one bit for each used variant starting from the lowest bit, so for the stack `max-lg:min-sm` we will set the bitfield to `0101` and those for the individual variants would result in `0100` (for `min-sm`) and `0001` (for `max-lg`). We then convert this bitfield to a number and order based on that number. This ensures that the stack always sorts higher. The issue now arises from the fact that the variant order also include the unprefixed variants for a breakpoint. So in our case of `lg` and `sm`, the full list would look like this: | Order | Variant | | ------------- | ------------- | | 0 | `max-lg` | | 1 | `max-sm` | | 2 | `min-sm` | | 3 | `sm` | | 4 | `min-lg` | | 5 | `lg` | This logic now breaks when you start to compute a stack for something like `max-lg:min-lg` _while also using the `lg` utility: | Stack | Bitmap | Integer Value | | ------------- | ------------- | ------------- | | `max-lg:min-lg` | `010001` | 17 | | `lg` | `100000` | 18 | As you can see here, the sole `lg` variant will now sort higher than the compound of `max-lg:min-lg`. That's not something we want! ## Proposed solution To fix this, we need to encode the information of _same_ variant order somehow. A single array like the example above is not sufficient for this, since it will remove the information of the similar sort order. Instead, we now computed a list of nested arrays for the order lookup that will combine variants of similar values (while keeping the order the same). So from the 6 item array above, we now have the following nested array: | Order | Variant | | ------------- | ------------- | | 0 | [`max-lg`] | | 1 | [`max-sm`] | | 2 | [`min-sm`, `sm`] | | 3 | [`min-lg`, `lg`] | When we use the first layer index for the bitfield, we can now see how this solves the issue: | Stack | Bitmap | Integer Value | | ------------- | ------------- | ------------- | | `max-lg:min-lg` | `1001` | 9 | | `lg` | `1000` | 8 | That's pretty-much it! There are a few other changes in this PR that mostly handles with a small regression by this change where now, named `group` variants and unnamed `group` variants would now have the same order (something that was undefined behavior before). --------- Co-authored-by: Adam Wathan <[email protected]>
1 parent c7cbdb8 commit ebaff18

File tree

7 files changed

+135
-53
lines changed

7 files changed

+135
-53
lines changed

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
1010
### Fixed
1111

1212
- Support `borderRadius.*` as an alias for `--radius-*` when using dot notation inside the `theme()` function ([#14436](https://github.com/tailwindlabs/tailwindcss/pull/14436))
13+
- Ensure individual variants from groups are always sorted earlier than stacked variants from the same groups ([#14431](https://github.com/tailwindlabs/tailwindcss/pull/14431))
1314

1415
## [4.0.0-alpha.24] - 2024-09-11
1516

packages/tailwindcss/src/compat/plugin-api.test.ts

Lines changed: 24 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -1404,13 +1404,13 @@ describe('matchVariant', () => {
14041404

14051405
expect(optimizeCss(compiled).trim()).toMatchInlineSnapshot(`
14061406
"@layer utilities {
1407-
.potato-yellow .potato-\\[yellow\\]\\:underline {
1408-
text-decoration-line: underline;
1409-
}
1410-
14111407
.potato-baked .potato-\\[baked\\]\\:flex {
14121408
display: flex;
14131409
}
1410+
1411+
.potato-yellow .potato-\\[yellow\\]\\:underline {
1412+
text-decoration-line: underline;
1413+
}
14141414
}"
14151415
`)
14161416
})
@@ -1435,17 +1435,17 @@ describe('matchVariant', () => {
14351435

14361436
expect(optimizeCss(compiled).trim()).toMatchInlineSnapshot(`
14371437
"@layer utilities {
1438-
@media (potato: yellow) {
1439-
.potato-\\[yellow\\]\\:underline {
1440-
text-decoration-line: underline;
1441-
}
1442-
}
1443-
14441438
@media (potato: baked) {
14451439
.potato-\\[baked\\]\\:flex {
14461440
display: flex;
14471441
}
14481442
}
1443+
1444+
@media (potato: yellow) {
1445+
.potato-\\[yellow\\]\\:underline {
1446+
text-decoration-line: underline;
1447+
}
1448+
}
14491449
}"
14501450
`)
14511451
})
@@ -1473,18 +1473,18 @@ describe('matchVariant', () => {
14731473

14741474
expect(optimizeCss(compiled).trim()).toMatchInlineSnapshot(`
14751475
"@layer utilities {
1476-
@media (potato: yellow) {
1476+
@media (potato: baked) {
14771477
@supports (font: bold) {
1478-
.potato-\\[yellow\\]\\:underline:large-potato {
1479-
text-decoration-line: underline;
1478+
.potato-\\[baked\\]\\:flex:large-potato {
1479+
display: flex;
14801480
}
14811481
}
14821482
}
14831483
1484-
@media (potato: baked) {
1484+
@media (potato: yellow) {
14851485
@supports (font: bold) {
1486-
.potato-\\[baked\\]\\:flex:large-potato {
1487-
display: flex;
1486+
.potato-\\[yellow\\]\\:underline:large-potato {
1487+
text-decoration-line: underline;
14881488
}
14891489
}
14901490
}
@@ -1541,10 +1541,10 @@ describe('matchVariant', () => {
15411541
return ({ matchVariant }: PluginAPI) => {
15421542
matchVariant('alphabet', (side) => `&${side}`, {
15431543
values: {
1544-
a: '[data-value="a"]',
1545-
b: '[data-value="b"]',
1546-
c: '[data-value="c"]',
1547-
d: '[data-value="d"]',
1544+
d: '[data-order="1"]',
1545+
a: '[data-order="2"]',
1546+
c: '[data-order="3"]',
1547+
b: '[data-order="4"]',
15481548
},
15491549
})
15501550
}
@@ -1560,19 +1560,19 @@ describe('matchVariant', () => {
15601560

15611561
expect(optimizeCss(compiled).trim()).toMatchInlineSnapshot(`
15621562
"@layer utilities {
1563-
.alphabet-a\\:underline[data-value="a"] {
1563+
.alphabet-d\\:underline[data-order="1"] {
15641564
text-decoration-line: underline;
15651565
}
15661566
1567-
.alphabet-b\\:underline[data-value="b"] {
1567+
.alphabet-a\\:underline[data-order="2"] {
15681568
text-decoration-line: underline;
15691569
}
15701570
1571-
.alphabet-c\\:underline[data-value="c"] {
1571+
.alphabet-c\\:underline[data-order="3"] {
15721572
text-decoration-line: underline;
15731573
}
15741574
1575-
.alphabet-d\\:underline[data-value="d"] {
1575+
.alphabet-b\\:underline[data-order="4"] {
15761576
text-decoration-line: underline;
15771577
}
15781578
}"

packages/tailwindcss/src/compat/plugin-api.ts

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -150,10 +150,10 @@ export function buildPluginApi(
150150
return 0
151151
}
152152

153-
if (options && typeof options.sort === 'function') {
154-
let aValue = options.values?.[a.value.value] ?? a.value.value
155-
let zValue = options.values?.[z.value.value] ?? z.value.value
153+
let aValue = options?.values?.[a.value.value] ?? a.value.value
154+
let zValue = options?.values?.[z.value.value] ?? z.value.value
156155

156+
if (options && typeof options.sort === 'function') {
157157
return options.sort(
158158
{ value: aValue, modifier: a.modifier?.value ?? null },
159159
{ value: zValue, modifier: z.modifier?.value ?? null },
@@ -163,6 +163,7 @@ export function buildPluginApi(
163163
let aOrder = defaultOptionKeys.indexOf(a.value.value)
164164
let zOrder = defaultOptionKeys.indexOf(z.value.value)
165165

166+
if (aOrder - zOrder === 0) return aValue < zValue ? -1 : 1
166167
return aOrder - zOrder
167168
},
168169
)

packages/tailwindcss/src/compile.ts

Lines changed: 2 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -30,10 +30,7 @@ export function compileCandidates(
3030
matches.set(rawCandidate, candidates)
3131
}
3232

33-
// Sort the variants
34-
let variants = designSystem.getUsedVariants().sort((a, z) => {
35-
return designSystem.variants.compare(a, z)
36-
})
33+
let variantOrderMap = designSystem.getVariantOrder()
3734

3835
// Create the AST
3936
for (let [rawCandidate, candidates] of matches) {
@@ -51,7 +48,7 @@ export function compileCandidates(
5148
// variants used.
5249
let variantOrder = 0n
5350
for (let variant of candidate.variants) {
54-
variantOrder |= 1n << BigInt(variants.indexOf(variant))
51+
variantOrder |= 1n << BigInt(variantOrderMap.get(variant)!)
5552
}
5653

5754
nodeSorting.set(node, {

packages/tailwindcss/src/design-system.ts

Lines changed: 29 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import { toCss } from './ast'
2-
import { parseCandidate, parseVariant, type Candidate } from './candidate'
2+
import { parseCandidate, parseVariant, type Candidate, type Variant } from './candidate'
33
import { compileAstNodes, compileCandidates } from './compile'
44
import { getClassList, getVariants, type ClassEntry, type VariantEntry } from './intellisense'
55
import { getClassOrder } from './sort'
@@ -13,17 +13,19 @@ export type DesignSystem = {
1313
utilities: Utilities
1414
variants: Variants
1515

16-
candidatesToCss(classes: string[]): (string | null)[]
1716
getClassOrder(classes: string[]): [string, bigint | null][]
1817
getClassList(): ClassEntry[]
1918
getVariants(): VariantEntry[]
2019

2120
parseCandidate(candidate: string): Candidate[]
22-
parseVariant(variant: string): ReturnType<typeof parseVariant>
21+
parseVariant(variant: string): Variant | null
2322
compileAstNodes(candidate: Candidate): ReturnType<typeof compileAstNodes>
2423

25-
getUsedVariants(): ReturnType<typeof parseVariant>[]
24+
getVariantOrder(): Map<Variant, number>
2625
resolveThemeValue(path: string): string | undefined
26+
27+
// Used by IntelliSense
28+
candidatesToCss(classes: string[]): (string | null)[]
2729
}
2830

2931
export function buildDesignSystem(theme: Theme): DesignSystem {
@@ -77,8 +79,29 @@ export function buildDesignSystem(theme: Theme): DesignSystem {
7779
compileAstNodes(candidate: Candidate) {
7880
return compiledAstNodes.get(candidate)
7981
},
80-
getUsedVariants() {
81-
return Array.from(parsedVariants.values())
82+
getVariantOrder() {
83+
let variants = Array.from(parsedVariants.values())
84+
variants.sort((a, z) => this.variants.compare(a, z))
85+
86+
let order = new Map<Variant, number>()
87+
let prevVariant: Variant | undefined = undefined
88+
let index: number = 0
89+
90+
for (let variant of variants) {
91+
if (variant === null) {
92+
continue
93+
}
94+
// This variant is not the same order as the previous one
95+
// so it goes into a new group
96+
if (prevVariant !== undefined && this.variants.compare(prevVariant, variant) !== 0) {
97+
index++
98+
}
99+
100+
order.set(variant, index)
101+
prevVariant = variant
102+
}
103+
104+
return order
82105
},
83106

84107
resolveThemeValue(path: `${ThemeKey}` | `${ThemeKey}${string}`) {

packages/tailwindcss/src/variants.test.ts

Lines changed: 62 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -1265,6 +1265,57 @@ test('sorting stacked min-* and max-* variants', async () => {
12651265
`)
12661266
})
12671267

1268+
test('stacked min-* and max-* variants should come after unprefixed variants', async () => {
1269+
expect(
1270+
await compileCss(
1271+
css`
1272+
@theme {
1273+
/* Explicitly ordered in a strange way */
1274+
--breakpoint-sm: 640px;
1275+
--breakpoint-lg: 1024px;
1276+
--breakpoint-md: 768px;
1277+
}
1278+
@tailwind utilities;
1279+
`,
1280+
['sm:flex', 'min-sm:max-lg:flex', 'md:flex', 'min-md:max-lg:flex'],
1281+
),
1282+
).toMatchInlineSnapshot(`
1283+
":root {
1284+
--breakpoint-sm: 640px;
1285+
--breakpoint-lg: 1024px;
1286+
--breakpoint-md: 768px;
1287+
}
1288+
1289+
@media (width >= 640px) {
1290+
.sm\\:flex {
1291+
display: flex;
1292+
}
1293+
}
1294+
1295+
@media (width >= 640px) {
1296+
@media (width < 1024px) {
1297+
.min-sm\\:max-lg\\:flex {
1298+
display: flex;
1299+
}
1300+
}
1301+
}
1302+
1303+
@media (width >= 768px) {
1304+
.md\\:flex {
1305+
display: flex;
1306+
}
1307+
}
1308+
1309+
@media (width >= 768px) {
1310+
@media (width < 1024px) {
1311+
.min-md\\:max-lg\\:flex {
1312+
display: flex;
1313+
}
1314+
}
1315+
}"
1316+
`)
1317+
})
1318+
12681319
test('min, max and unprefixed breakpoints', async () => {
12691320
expect(
12701321
await compileCss(
@@ -2246,14 +2297,14 @@ test('container queries', async () => {
22462297
--width-lg: 1024px;
22472298
}
22482299
2249-
@container (width < 1024px) {
2250-
.\\@max-lg\\:flex {
2300+
@container name (width < 1024px) {
2301+
.\\@max-lg\\/name\\:flex {
22512302
display: flex;
22522303
}
22532304
}
22542305
2255-
@container name (width < 1024px) {
2256-
.\\@max-lg\\/name\\:flex {
2306+
@container (width < 1024px) {
2307+
.\\@max-lg\\:flex {
22572308
display: flex;
22582309
}
22592310
}
@@ -2294,20 +2345,14 @@ test('container queries', async () => {
22942345
}
22952346
}
22962347
2297-
@container (width >= 1024px) {
2298-
.\\@lg\\:flex {
2299-
display: flex;
2300-
}
2301-
}
2302-
23032348
@container name (width >= 1024px) {
23042349
.\\@lg\\/name\\:flex {
23052350
display: flex;
23062351
}
23072352
}
23082353
23092354
@container (width >= 1024px) {
2310-
.\\@min-lg\\:flex {
2355+
.\\@lg\\:flex {
23112356
display: flex;
23122357
}
23132358
}
@@ -2316,6 +2361,12 @@ test('container queries', async () => {
23162361
.\\@min-lg\\/name\\:flex {
23172362
display: flex;
23182363
}
2364+
}
2365+
2366+
@container (width >= 1024px) {
2367+
.\\@min-lg\\:flex {
2368+
display: flex;
2369+
}
23192370
}"
23202371
`)
23212372
})

packages/tailwindcss/src/variants.ts

Lines changed: 13 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -117,13 +117,22 @@ export class Variants {
117117
if (orderedByVariant !== 0) return orderedByVariant
118118

119119
if (a.kind === 'compound' && z.kind === 'compound') {
120-
return this.compare(a.variant, z.variant)
120+
let order = this.compare(a.variant, z.variant)
121+
if (order === 0) {
122+
if (a.modifier && z.modifier) {
123+
return a.modifier.value < z.modifier.value ? -1 : 1
124+
} else if (a.modifier) {
125+
return 1
126+
} else if (z.modifier) {
127+
return -1
128+
}
129+
}
130+
return order
121131
}
122132

123133
let compareFn = this.compareFns.get(aOrder)
124-
if (compareFn === undefined) return 0
125-
126-
return compareFn(a, z) || (a.root < z.root ? -1 : 1)
134+
if (compareFn === undefined) return a.root < z.root ? -1 : 1
135+
return compareFn(a, z)
127136
}
128137

129138
keys() {

0 commit comments

Comments
 (0)