Handle utilities with multiple and/or grouped selectors better (#8262)

* Add failing test cases

* Flatten finalizeSelector code

* Use AST operations to format selector classes

With this change we only parse the selector once and operate on the AST until we need to turn it back into a selector. In addition this lets us solve an issue where .replace(…) did the wrong thing because it doesn’t understand that .base and .base-foo are two different classes

* Remove extraneous, non-matching selectors from utilities

* Update changelog
This commit is contained in:
Jordan Pittman 2022-05-03 13:10:27 -04:00 committed by GitHub
parent 7c337f24fc
commit 1402be2dd0
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
3 changed files with 192 additions and 52 deletions

View File

@ -19,6 +19,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- Support PostCSS config options in config file in CLI ([#8226](https://github.com/tailwindlabs/tailwindcss/pull/8226))
- Remove default `[hidden]` style in preflight ([#8248](https://github.com/tailwindlabs/tailwindcss/pull/8248))
- Only check selectors containing base apply candidates for circular dependencies ([#8222](https://github.com/tailwindlabs/tailwindcss/pull/8222))
- Handle utilities with multiple and/or grouped selectors better ([#8262](https://github.com/tailwindlabs/tailwindcss/pull/8262))
### Added

View File

@ -30,6 +30,8 @@ export function formatVariantSelector(current, ...others) {
}
export function finalizeSelector(format, { selector, candidate, context }) {
let ast = selectorParser().astSync(selector)
let separator = context?.tailwindConfig?.separator ?? ':'
// Split by the separator, but ignore the separator inside square brackets:
@ -48,6 +50,19 @@ export function finalizeSelector(format, { selector, candidate, context }) {
format = format.replace(PARENT, `.${escapeClassName(candidate)}`)
let formatAst = selectorParser().astSync(format)
// Remove extraneous selectors that do not include the base class/candidate being matched against
// For example if we have a utility defined `.a, .b { color: red}`
// And the formatted variant is sm:b then we want the final selector to be `.sm\:b` and not `.a, .sm\:b`
ast.each((node) => {
let hasClassesMatchingCandidate = node.some((n) => n.type === 'class' && n.value === base)
if (!hasClassesMatchingCandidate) {
node.remove()
}
})
// Normalize escaped classes, e.g.:
//
// The idea would be to replace the escaped `base` in the selector with the
@ -59,65 +74,61 @@ export function finalizeSelector(format, { selector, candidate, context }) {
// base in selector: bg-\\[rgb\\(255\\,0\\,0\\)\\]
// escaped base: bg-\\[rgb\\(255\\2c 0\\2c 0\\)\\]
//
selector = selectorParser((selectors) => {
return selectors.walkClasses((node) => {
if (node.raws && node.value.includes(base)) {
node.raws.value = escapeClassName(unescape(node.raws.value))
}
return node
})
}).processSync(selector)
ast.walkClasses((node) => {
if (node.raws && node.value.includes(base)) {
node.raws.value = escapeClassName(unescape(node.raws.value))
}
})
// We can safely replace the escaped base now, since the `base` section is
// now in a normalized escaped value.
selector = selector.replace(`.${escapeClassName(base)}`, format)
ast.walkClasses((node) => {
if (node.value === base) {
node.replaceWith(...formatAst.nodes)
}
})
// This will make sure to move pseudo's to the correct spot (the end for
// pseudo elements) because otherwise the selector will never work
// anyway.
//
// E.g.:
// - `before:hover:text-center` would result in `.before\:hover\:text-center:hover::before`
// - `hover:before:text-center` would result in `.hover\:before\:text-center:hover::before`
//
// `::before:hover` doesn't work, which means that we can make it work for you by flipping the order.
function collectPseudoElements(selector) {
let nodes = []
for (let node of selector.nodes) {
if (isPseudoElement(node)) {
nodes.push(node)
selector.removeChild(node)
}
if (node?.nodes) {
nodes.push(...collectPseudoElements(node))
}
}
return nodes
}
// Remove unnecessary pseudo selectors that we used as placeholders
return selectorParser((selectors) => {
return selectors.map((selector) => {
selector.walkPseudos((p) => {
if (selectorFunctions.has(p.value)) {
p.replaceWith(p.nodes)
}
return p
})
// This will make sure to move pseudo's to the correct spot (the end for
// pseudo elements) because otherwise the selector will never work
// anyway.
//
// E.g.:
// - `before:hover:text-center` would result in `.before\:hover\:text-center:hover::before`
// - `hover:before:text-center` would result in `.hover\:before\:text-center:hover::before`
//
// `::before:hover` doesn't work, which means that we can make it work for you by flipping the order.
function collectPseudoElements(selector) {
let nodes = []
for (let node of selector.nodes) {
if (isPseudoElement(node)) {
nodes.push(node)
selector.removeChild(node)
}
if (node?.nodes) {
nodes.push(...collectPseudoElements(node))
}
}
return nodes
ast.each((selector) => {
selector.walkPseudos((p) => {
if (selectorFunctions.has(p.value)) {
p.replaceWith(p.nodes)
}
let pseudoElements = collectPseudoElements(selector)
if (pseudoElements.length > 0) {
selector.nodes.push(pseudoElements.sort(sortSelector))
}
return selector
})
}).processSync(selector)
let pseudoElements = collectPseudoElements(selector)
if (pseudoElements.length > 0) {
selector.nodes.push(pseudoElements.sort(sortSelector))
}
})
return ast.toString()
}
// Note: As a rule, double colons (::) should be used instead of a single colon

View File

@ -603,3 +603,131 @@ it('appends variants to the correct place when using postcss documents', () => {
`)
})
})
it('variants support multiple, grouped selectors (html)', () => {
let config = {
content: [{ raw: html`<div class="sm:base1 sm:base2"></div>` }],
plugins: [],
corePlugins: { preflight: false },
}
let input = css`
@tailwind utilities;
@layer utilities {
.base1 .foo,
.base1 .bar {
color: red;
}
.base2 .bar .base2-foo {
color: red;
}
}
`
return run(input, config).then((result) => {
return expect(result.css).toMatchFormattedCss(css`
@media (min-width: 640px) {
.sm\:base1 .foo,
.sm\:base1 .bar {
color: red;
}
.sm\:base2 .bar .base2-foo {
color: red;
}
}
`)
})
})
it('variants support multiple, grouped selectors (apply)', () => {
let config = {
content: [{ raw: html`<div class="baz"></div>` }],
plugins: [],
corePlugins: { preflight: false },
}
let input = css`
@tailwind utilities;
@layer utilities {
.base .foo,
.base .bar {
color: red;
}
}
.baz {
@apply sm:base;
}
`
return run(input, config).then((result) => {
return expect(result.css).toMatchFormattedCss(css`
@media (min-width: 640px) {
.baz .foo,
.baz .bar {
color: red;
}
}
`)
})
})
it('variants only picks the used selectors in a group (html)', () => {
let config = {
content: [{ raw: html`<div class="sm:b"></div>` }],
plugins: [],
corePlugins: { preflight: false },
}
let input = css`
@tailwind utilities;
@layer utilities {
.a,
.b {
color: red;
}
}
`
return run(input, config).then((result) => {
return expect(result.css).toMatchFormattedCss(css`
@media (min-width: 640px) {
.sm\:b {
color: red;
}
}
`)
})
})
it('variants only picks the used selectors in a group (apply)', () => {
let config = {
content: [{ raw: html`<div class="baz"></div>` }],
plugins: [],
corePlugins: { preflight: false },
}
let input = css`
@tailwind utilities;
@layer utilities {
.a,
.b {
color: red;
}
}
.baz {
@apply sm:b;
}
`
return run(input, config).then((result) => {
return expect(result.css).toMatchFormattedCss(css`
@media (min-width: 640px) {
.baz {
color: red;
}
}
`)
})
})