Skip to content

Commit ef74fd3

Browse files
Fix @apply selector rewriting when multiple classes are involved (#9107)
* Rewrite `replaceSelector` using `postcss-selector-parser` * Sort classes between tags and pseudos when rewriting selectors * Update changelog
1 parent b0018e2 commit ef74fd3

File tree

3 files changed

+154
-19
lines changed

3 files changed

+154
-19
lines changed

CHANGELOG.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
1717

1818
- Use absolute paths when resolving changed files for resilience against working directory changes ([#9032](https://github.com/tailwindlabs/tailwindcss/pull/9032))
1919
- Fix ring color utility generation when using `respectDefaultRingColorOpacity` ([#9070](https://github.com/tailwindlabs/tailwindcss/pull/9070))
20+
- Replace all occurrences of a class in a selector when using `@apply` ([#9107](https://github.com/tailwindlabs/tailwindcss/pull/9107))
21+
- Sort tags before classes when `@applying` a selector with joined classes ([#9107](https://github.com/tailwindlabs/tailwindcss/pull/9107))
2022

2123
## [3.1.8] - 2022-08-05
2224

src/lib/expandApplyAtRules.js

Lines changed: 50 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -34,13 +34,13 @@ function extractClasses(node) {
3434
return Object.assign(classes, { groups: normalizedGroups })
3535
}
3636

37-
let selectorExtractor = parser((root) => root.nodes.map((node) => node.toString()))
37+
let selectorExtractor = parser()
3838

3939
/**
4040
* @param {string} ruleSelectors
4141
*/
4242
function extractSelectors(ruleSelectors) {
43-
return selectorExtractor.transformSync(ruleSelectors)
43+
return selectorExtractor.astSync(ruleSelectors)
4444
}
4545

4646
function extractBaseCandidates(candidates, separator) {
@@ -299,30 +299,61 @@ function processApply(root, context, localCache) {
299299
* What happens in this function is that we prepend a `.` and escape the candidate.
300300
* This will result in `.hover\:font-bold`
301301
* Which means that we can replace `.hover\:font-bold` with `.abc` in `.hover\:font-bold:hover` resulting in `.abc:hover`
302+
*
303+
* @param {string} selector
304+
* @param {string} utilitySelectors
305+
* @param {string} candidate
302306
*/
303-
// TODO: Should we use postcss-selector-parser for this instead?
304307
function replaceSelector(selector, utilitySelectors, candidate) {
305-
let needle = `.${escapeClassName(candidate)}`
306-
let needles = [...new Set([needle, needle.replace(/\\2c /g, '\\,')])]
308+
let selectorList = extractSelectors(selector)
307309
let utilitySelectorsList = extractSelectors(utilitySelectors)
310+
let candidateList = extractSelectors(`.${escapeClassName(candidate)}`)
311+
let candidateClass = candidateList.nodes[0].nodes[0]
308312

309-
return extractSelectors(selector)
310-
.map((s) => {
311-
let replaced = []
313+
selectorList.each((sel) => {
314+
/** @type {Set<import('postcss-selector-parser').Selector>} */
315+
let replaced = new Set()
312316

313-
for (let utilitySelector of utilitySelectorsList) {
314-
let replacedSelector = utilitySelector
315-
for (const needle of needles) {
316-
replacedSelector = replacedSelector.replace(needle, s)
317-
}
318-
if (replacedSelector === utilitySelector) {
319-
continue
317+
utilitySelectorsList.each((utilitySelector) => {
318+
utilitySelector = utilitySelector.clone()
319+
320+
utilitySelector.walkClasses((node) => {
321+
if (node.value !== candidateClass.value) {
322+
return
320323
}
321-
replaced.push(replacedSelector)
322-
}
323-
return replaced.join(', ')
324+
325+
// Since you can only `@apply` class names this is sufficient
326+
// We want to replace the matched class name with the selector the user is using
327+
// Ex: Replace `.text-blue-500` with `.foo.bar:is(.something-cool)`
328+
node.replaceWith(...sel.nodes.map((node) => node.clone()))
329+
330+
// Record that we did something and we want to use this new selector
331+
replaced.add(utilitySelector)
332+
})
324333
})
325-
.join(', ')
334+
335+
// Sort tag names before class names
336+
// This happens when replacing `.bar` in `.foo.bar` with a tag like `section`
337+
for (const sel of replaced) {
338+
sel.sort((a, b) => {
339+
if (a.type === 'tag' && b.type === 'class') {
340+
return -1
341+
} else if (a.type === 'class' && b.type === 'tag') {
342+
return 1
343+
} else if (a.type === 'class' && b.type === 'pseudo') {
344+
return -1
345+
} else if (a.type === 'pseudo' && b.type === 'class') {
346+
return 1
347+
}
348+
349+
return sel.index(a) - sel.index(b)
350+
})
351+
}
352+
353+
sel.replaceWith(...replaced)
354+
})
355+
356+
return selectorList.toString()
326357
}
327358

328359
let perParentApplies = new Map()

tests/apply.test.js

Lines changed: 102 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1584,3 +1584,105 @@ it('can apply user utilities that start with a dash', async () => {
15841584
}
15851585
`)
15861586
})
1587+
1588+
it('can apply joined classes when using elements', async () => {
1589+
let config = {
1590+
content: [{ raw: html`<div class="foo-1 -foo-1 new-class"></div>` }],
1591+
plugins: [],
1592+
}
1593+
1594+
let input = css`
1595+
.foo.bar {
1596+
color: red;
1597+
}
1598+
.bar.foo {
1599+
color: green;
1600+
}
1601+
header:nth-of-type(odd) {
1602+
@apply foo;
1603+
}
1604+
main {
1605+
@apply foo bar;
1606+
}
1607+
footer {
1608+
@apply bar;
1609+
}
1610+
`
1611+
1612+
let result = await run(input, config)
1613+
1614+
expect(result.css).toMatchFormattedCss(css`
1615+
.foo.bar {
1616+
color: red;
1617+
}
1618+
.bar.foo {
1619+
color: green;
1620+
}
1621+
header.bar:nth-of-type(odd) {
1622+
color: red;
1623+
color: green;
1624+
}
1625+
main.bar {
1626+
color: red;
1627+
}
1628+
main.foo {
1629+
color: red;
1630+
}
1631+
main.bar {
1632+
color: green;
1633+
}
1634+
main.foo {
1635+
color: green;
1636+
}
1637+
footer.foo {
1638+
color: red;
1639+
color: green;
1640+
}
1641+
`)
1642+
})
1643+
1644+
it('can produce selectors that replace multiple instances of the same class', async () => {
1645+
let config = {
1646+
content: [{ raw: html`<div class="foo-1 -foo-1 new-class"></div>` }],
1647+
plugins: [],
1648+
}
1649+
1650+
let input = css`
1651+
.foo + .foo {
1652+
color: blue;
1653+
}
1654+
.bar + .bar {
1655+
color: fuchsia;
1656+
}
1657+
header {
1658+
@apply foo;
1659+
}
1660+
main {
1661+
@apply foo bar;
1662+
}
1663+
footer {
1664+
@apply bar;
1665+
}
1666+
`
1667+
1668+
let result = await run(input, config)
1669+
1670+
expect(result.css).toMatchFormattedCss(css`
1671+
.foo + .foo {
1672+
color: blue;
1673+
}
1674+
.bar + .bar {
1675+
color: fuchsia;
1676+
}
1677+
header + header {
1678+
color: blue;
1679+
}
1680+
main + main {
1681+
color: blue;
1682+
color: fuchsia;
1683+
}
1684+
footer + footer {
1685+
color: fuchsia;
1686+
}
1687+
`)
1688+
})

0 commit comments

Comments
 (0)