Repository: tailwindlabs/tailwindcss
Pull request #6018 (2021-11-08 21:15:41)

Enforce the order of some variants
The order of variants can be important for certain scenario's.

For example, if you have:
```html
<div class="group-hover:dark:text-center"></div>
```

This would result in:
```css
.group:hover .dark .group-hover\:dark\:text-center {
  text-align: center;
}
```
This requires the following DOM structure:
```html
<div class="group">
  <div class="dark">
    <div class="group-hover:dark:text-center"></div>
  </div>
</div>
```

However, switching the order might be useful.

```html
<div class="dark:group-hover:text-center"></div>
```

This would result in:
```css
.dark .group:hover .dark\:group-hover\:text-center {
  text-align: center;
}
```

This requires the following DOM structure:
```html
<div class="dark">
  <div class="group">
    <div class="group-hover:dark:text-center"></div>
  </div>
</div>
```

This is a contrived example, but there are scenarios (see our Typography plugin) where it does make sense to switch the order sometimes.
However, in some scenario's it doesn't make sense because you would generate invalid css selectors (well.. not invalid, but selectors that won't work).

Such an example is when you have `hover:before:text-center` vs `before:hover:text-center`. This would result in:
```css
.hover\:before\:text-center::before:hover { /* This is useless, because you can't hover a ::before element */
  content: var(--tw-content);
  text-align: center;
}

.before\:hover\:text-center:hover::before {
  content: var(--tw-content);
  text-align: center;
}
```

To fix this, we move all the pseudo elements to the back of the selector, otherwise the selector wouldn't even work. This is implemented on a selector level and not on a variant API level. Which means that this would also work for third party plugins.
With this PR, the examples we used earlier (`hover:before:text-center` vs `before:hover:text-center`) would result in:

```css
.hover\:before\:text-center:hover::before { /* This now has :hover::before, even if the class in the HTML is different */
  content: var(--tw-content);
  text-align: center;
}

.before\:hover\:text-center:hover::before {
  content: var(--tw-content);
  text-align: center;
}
```

The cool part here is that we didn't change the base selector found in the HTML. This is because of the new `addVariant` API introduced in #5809.
We only append and prepend selector parts to the existing base class (the candidate found in the HTML), instead of re-building the selector from scratch.

This PR does mean that if you used `hover:before:text-center` in one file, and `before:hover:text-center` in the other, that there is a bit of duplication going on. Maybe that's fine, maybe in the future we can warn about this to enforce consistency. But at least it will work now instead of introducing a bug.

Fixes: #6016

<!--

👋 Hey, thanks for your interest in contributing to Tailwind!

**Please ask first before starting work on any significant new features.**

It's never a fun experience to have your pull request declined after investing a lot of time and effort into a new feature. To avoid this from happening, we request that contributors create an issue to first discuss any significant new features. This includes things like adding new utilities, creating new at-rules, or adding new component examples to the documentation.

https://github.com/tailwindcss/tailwindcss/blob/master/.github/CONTRIBUTING.md

-->



Patch:
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 1504a01b..532c71f5 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -9,6 +9,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
 
 Nothing yet!
 
+### Fixed
+
+- Enforce the order of some variants (like `before` and `after`) ([#6018](https://github.com/tailwindlabs/tailwindcss/pull/6018))
+
 ## [3.0.0-alpha.2] - 2021-11-08
 
 ### Changed
diff --git a/src/util/formatVariantSelector.js b/src/util/formatVariantSelector.js
index d24471cc..06d0f489 100644
--- a/src/util/formatVariantSelector.js
+++ b/src/util/formatVariantSelector.js
@@ -74,11 +74,92 @@ export function finalizeSelector(format, { selector, candidate, context }) {
         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
+      }
+
+      let pseudoElements = collectPseudoElements(selector)
+      if (pseudoElements.length > 0) {
+        selector.nodes.push(pseudoElements.sort(sortSelector))
+      }
+
       return selector
     })
   }).processSync(selector)
 }
 
+// Note: As a rule, double colons (::) should be used instead of a single colon
+// (:). This distinguishes pseudo-classes from pseudo-elements. However, since
+// this distinction was not present in older versions of the W3C spec, most
+// browsers support both syntaxes for the original pseudo-elements.
+let pseudoElementsBC = [':before', ':after', ':first-line', ':first-letter']
+
+// These pseudo-elements _can_ be combined with other pseudo selectors AND the order does matter.
+let pseudoElementExceptions = ['::file-selector-button']
+
+// 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 sortSelector(a, z) {
+  // Both nodes are non-pseudo's so we can safely ignore them and keep
+  // them in the same order.
+  if (a.type !== 'pseudo' && z.type !== 'pseudo') {
+    return 0
+  }
+
+  // If one of them is a combinator, we need to keep it in the same order
+  // because that means it will start a new "section" in the selector.
+  if ((a.type === 'combinator') ^ (z.type === 'combinator')) {
+    return 0
+  }
+
+  // One of the items is a pseudo and the other one isn't. Let's move
+  // the pseudo to the right.
+  if ((a.type === 'pseudo') ^ (z.type === 'pseudo')) {
+    return (a.type === 'pseudo') - (z.type === 'pseudo')
+  }
+
+  // Both are pseudo's, move the pseudo elements (except for
+  // ::file-selector-button) to the right.
+  return isPseudoElement(a) - isPseudoElement(z)
+}
+
+function isPseudoElement(node) {
+  if (node.type !== 'pseudo') return false
+  if (pseudoElementExceptions.includes(node.value)) return false
+
+  return node.value.startsWith('::') || pseudoElementsBC.includes(node.value)
+}
+
 function resolveFunctionArgument(haystack, needle, arg) {
   let startIdx = haystack.indexOf(arg ? `${needle}(${arg})` : needle)
   if (startIdx === -1) return null


Test patch:
diff --git a/tests/format-variant-selector.test.js b/tests/format-variant-selector.test.js
index a32ae502..94e86ddf 100644
--- a/tests/format-variant-selector.test.js
+++ b/tests/format-variant-selector.test.js
@@ -259,3 +259,26 @@ describe('real examples', () => {
     })
   })
 })
+
+describe('pseudo elements', () => {
+  it.each`
+    before                                                   | after
+    ${'&::before'}                                           | ${'&::before'}
+    ${'&::before:hover'}                                     | ${'&:hover::before'}
+    ${'&:before:hover'}                                      | ${'&:hover:before'}
+    ${'&::file-selector-button:hover'}                       | ${'&::file-selector-button:hover'}
+    ${'&:hover::file-selector-button'}                       | ${'&:hover::file-selector-button'}
+    ${'.parent:hover &'}                                     | ${'.parent:hover &'}
+    ${'.parent::before &'}                                   | ${'.parent &::before'}
+    ${'.parent::before &:hover'}                             | ${'.parent &:hover::before'}
+    ${':where(&::before) :is(h1, h2, h3, h4)'}               | ${':where(&) :is(h1, h2, h3, h4)::before'}
+    ${':where(&::file-selector-button) :is(h1, h2, h3, h4)'} | ${':where(&::file-selector-button) :is(h1, h2, h3, h4)'}
+  `('should translate "$before" into "$after"', ({ before, after }) => {
+    let result = finalizeSelector(formatVariantSelector('&', before), {
+      selector: '.a',
+      candidate: 'a',
+    })
+
+    expect(result).toEqual(after.replace('&', '.a'))
+  })
+})
diff --git a/tests/parallel-variants.test.js b/tests/parallel-variants.test.js
index e92bee89..5d58ea34 100644
--- a/tests/parallel-variants.test.js
+++ b/tests/parallel-variants.test.js
@@ -27,7 +27,7 @@ test('basic parallel variants', async () => {
       .test\:font-medium *::test {
         font-weight: 500;
       }
-      .hover\:test\:font-black *::test:hover {
+      .hover\:test\:font-black *:hover::test {
         font-weight: 900;
       }
       .test\:font-bold::test {
@@ -36,7 +36,7 @@ test('basic parallel variants', async () => {
       .test\:font-medium::test {
         font-weight: 500;
       }
-      .hover\:test\:font-black::test:hover {
+      .hover\:test\:font-black:hover::test {
         font-weight: 900;
       }
     `)
diff --git a/tests/resolve-defaults-at-rules.test.js b/tests/resolve-defaults-at-rules.test.js
index ae8b3e90..99750779 100644
--- a/tests/resolve-defaults-at-rules.test.js
+++ b/tests/resolve-defaults-at-rules.test.js
@@ -252,12 +252,12 @@ test('with multi-class pseudo-element and pseudo-class variants', async () => {
           scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y));
       }
       /* --- */
-      .group:hover .group-hover\:hover\:before\:scale-x-110::before:hover {
+      .group:hover .group-hover\:hover\:before\:scale-x-110:hover::before {
         content: var(--tw-content);
         --tw-scale-x: 1.1;
         transform: var(--tw-transform);
       }
-      .peer:focus ~ .peer-focus\:focus\:after\:rotate-3::after:focus {
+      .peer:focus ~ .peer-focus\:focus\:after\:rotate-3:focus::after {
         content: var(--tw-content);
         --tw-rotate: 3deg;
         transform: var(--tw-transform);
diff --git a/tests/variants.test.js b/tests/variants.test.js
index f2d80f42..b241a11c 100644
--- a/tests/variants.test.js
+++ b/tests/variants.test.js
@@ -323,3 +323,63 @@ test('custom addVariant with nested media & format shorthand', () => {
     `)
   })
 })
+
+test('before and after variants are a bit special, and forced to the end', () => {
+  let config = {
+    content: [
+      {
+        raw: html`
+          <div class="before:hover:text-center"></div>
+          <div class="hover:before:text-center"></div>
+        `,
+      },
+    ],
+    plugins: [],
+  }
+
+  return run('@tailwind components;@tailwind utilities', config).then((result) => {
+    return expect(result.css).toMatchFormattedCss(css`
+      .before\:hover\:text-center:hover::before {
+        content: var(--tw-content);
+        text-align: center;
+      }
+
+      .hover\:before\:text-center:hover::before {
+        content: var(--tw-content);
+        text-align: center;
+      }
+    `)
+  })
+})
+
+test('before and after variants are a bit special, and forced to the end (2)', () => {
+  let config = {
+    content: [
+      {
+        raw: html`
+          <div class="before:prose-headings:text-center"></div>
+          <div class="prose-headings:before:text-center"></div>
+        `,
+      },
+    ],
+    plugins: [
+      function ({ addVariant }) {
+        addVariant('prose-headings', ':where(&) :is(h1, h2, h3, h4)')
+      },
+    ],
+  }
+
+  return run('@tailwind components;@tailwind utilities', config).then((result) => {
+    return expect(result.css).toMatchFormattedCss(css`
+      :where(.before\:prose-headings\:text-center) :is(h1, h2, h3, h4)::before {
+        content: var(--tw-content);
+        text-align: center;
+      }
+
+      :where(.prose-headings\:before\:text-center) :is(h1, h2, h3, h4)::before {
+        content: var(--tw-content);
+        text-align: center;
+      }
+    `)
+  })
+})