Repository: tailwindlabs/tailwindcss (TypeScript)

Issue:
after update from 3.2.7 to 3.3.0 pseudo elements `after` and  `before` doesn't get `dark: ...` anymore
<!-- Please provide all of the information requested below. We're a small team and without all of this information it's not possible for us to help and your bug report will be closed. -->

**What version of Tailwind CSS are you using?**

3.3.0

**What build tool (or framework if it abstracts the build tool) are you using?**

astro v2.1.8

**What version of Node.js are you using?**

19.8.1

**What browser are you using?**

Chrome 111

**What operating system are you using?**

Zorin OS 16.2

**Describe your issue**

after update every `after` and `before` pseudo element doesn't get any `dark` modifier value, for example if i write `... bg-gray-800 dark:bg-gray-100 ...` it get only `bg-gray-800` on version 3.2.7 it works as usual


Hints:
Hey, thank you for this bug report! 🙏

Can you please provide a minimal reproduction repo that shows the issue?

Trying to reproduce it and seems to work as expected: https://play.tailwindcss.com/d5HtkTSI6a
> Hey, thank you for this bug report! 🙏
> 
> Can you please provide a minimal reproduction repo that shows the issue?
> 
> Trying to reproduce it and seems to work as expected: https://play.tailwindcss.com/d5HtkTSI6a

I have reproduced a case here: https://play.tailwindcss.com/tsNrZ1Qtm5?file=css
> Hey, thank you for this bug report! pray
> 
> Can you please provide a minimal reproduction repo that shows the issue?
> 
> Trying to reproduce it and seems to work as expected: https://play.tailwindcss.com/d5HtkTSI6a

I tried to extract and abstract part of code of my use case: https://play.tailwindcss.com/ytDMhpXmfR?file=css

Patch:
diff --git a/src/lib/expandApplyAtRules.js b/src/lib/expandApplyAtRules.js
--- a/src/lib/expandApplyAtRules.js
+++ b/src/lib/expandApplyAtRules.js
@@ -4,6 +4,7 @@ import parser from 'postcss-selector-parser'
 import { resolveMatches } from './generateRules'
 import escapeClassName from '../util/escapeClassName'
 import { applyImportantSelector } from '../util/applyImportantSelector'
+import { collectPseudoElements, sortSelector } from '../util/formatVariantSelector.js'
 
 /** @typedef {Map<string, [any, import('postcss').Rule[]]>} ApplyCache */
 
@@ -562,6 +563,17 @@ function processApply(root, context, localCache) {
             rule.walkDecls((d) => {
               d.important = meta.important || important
             })
+
+            // Move pseudo elements to the end of the selector (if necessary)
+            let selector = parser().astSync(rule.selector)
+            selector.each((sel) => {
+              let [pseudoElements] = collectPseudoElements(sel)
+              if (pseudoElements.length > 0) {
+                sel.nodes.push(...pseudoElements.sort(sortSelector))
+              }
+            })
+
+            rule.selector = selector.toString()
           })
         }
 
diff --git a/src/util/applyImportantSelector.js b/src/util/applyImportantSelector.js
--- a/src/util/applyImportantSelector.js
+++ b/src/util/applyImportantSelector.js
@@ -1,19 +1,31 @@
 import { splitAtTopLevelOnly } from './splitAtTopLevelOnly'
+import parser from 'postcss-selector-parser'
+import { collectPseudoElements, sortSelector } from './formatVariantSelector.js'
 
 export function applyImportantSelector(selector, important) {
-  let matches = /^(.*?)(:before|:after|::[\w-]+)(\)*)$/g.exec(selector)
-  if (!matches) return `${important} ${wrapWithIs(selector)}`
+  let sel = parser().astSync(selector)
 
-  let [, before, pseudo, brackets] = matches
-  return `${important} ${wrapWithIs(before + brackets)}${pseudo}`
-}
+  sel.each((sel) => {
+    // Wrap with :is if it's not already wrapped
+    let isWrapped =
+      sel.nodes[0].type === 'pseudo' &&
+      sel.nodes[0].value === ':is' &&
+      sel.nodes.every((node) => node.type !== 'combinator')
 
-function wrapWithIs(selector) {
-  let parts = splitAtTopLevelOnly(selector, ' ')
+    if (!isWrapped) {
+      sel.nodes = [
+        parser.pseudo({
+          value: ':is',
+          nodes: [sel.clone()],
+        }),
+      ]
+    }
 
-  if (parts.length === 1 && parts[0].startsWith(':is(') && parts[0].endsWith(')')) {
-    return selector
-  }
+    let [pseudoElements] = collectPseudoElements(sel)
+    if (pseudoElements.length > 0) {
+      sel.nodes.push(...pseudoElements.sort(sortSelector))
+    }
+  })
 
-  return `:is(${selector})`
+  return `${important} ${sel.toString()}`
 }
diff --git a/src/util/formatVariantSelector.js b/src/util/formatVariantSelector.js
--- a/src/util/formatVariantSelector.js
+++ b/src/util/formatVariantSelector.js
@@ -246,9 +246,9 @@ export function finalizeSelector(current, formats, { context, candidate, base })
 
   // Move pseudo elements to the end of the selector (if necessary)
   selector.each((sel) => {
-    let pseudoElements = collectPseudoElements(sel)
+    let [pseudoElements] = collectPseudoElements(sel)
     if (pseudoElements.length > 0) {
-      sel.nodes.push(pseudoElements.sort(sortSelector))
+      sel.nodes.push(...pseudoElements.sort(sortSelector))
     }
   })
 
@@ -351,23 +351,45 @@ let pseudoElementExceptions = [
  * `::before:hover` doesn't work, which means that we can make it work for you by flipping the order.
  *
  * @param {Selector} selector
+ * @param {boolean} force
  **/
-function collectPseudoElements(selector) {
+export function collectPseudoElements(selector, force = false) {
   /** @type {Node[]} */
   let nodes = []
+  let seenPseudoElement = null
 
-  for (let node of selector.nodes) {
-    if (isPseudoElement(node)) {
+  for (let node of [...selector.nodes]) {
+    if (isPseudoElement(node, force)) {
       nodes.push(node)
       selector.removeChild(node)
+      seenPseudoElement = node.value
+    } else if (seenPseudoElement !== null) {
+      if (pseudoElementExceptions.includes(seenPseudoElement) && isPseudoClass(node, force)) {
+        nodes.push(node)
+        selector.removeChild(node)
+      } else {
+        seenPseudoElement = null
+      }
     }
 
     if (node?.nodes) {
-      nodes.push(...collectPseudoElements(node))
+      let hasPseudoElementRestrictions =
+        node.type === 'pseudo' && (node.value === ':is' || node.value === ':has')
+
+      let [collected, seenPseudoElementInSelector] = collectPseudoElements(
+        node,
+        force || hasPseudoElementRestrictions
+      )
+
+      if (seenPseudoElementInSelector) {
+        seenPseudoElement = seenPseudoElementInSelector
+      }
+
+      nodes.push(...collected)
     }
   }
 
-  return nodes
+  return [nodes, seenPseudoElement]
 }
 
 // This will make sure to move pseudo's to the correct spot (the end for
@@ -380,7 +402,7 @@ function collectPseudoElements(selector) {
 //
 // `::before:hover` doesn't work, which means that we can make it work
 // for you by flipping the order.
-function sortSelector(a, z) {
+export 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') {
@@ -404,9 +426,13 @@ function sortSelector(a, z) {
   return isPseudoElement(a) - isPseudoElement(z)
 }
 
-function isPseudoElement(node) {
+function isPseudoElement(node, force = false) {
   if (node.type !== 'pseudo') return false
-  if (pseudoElementExceptions.includes(node.value)) return false
+  if (pseudoElementExceptions.includes(node.value) && !force) return false
 
   return node.value.startsWith('::') || pseudoElementsBC.includes(node.value)
 }
+
+function isPseudoClass(node, force) {
+  return node.type === 'pseudo' && !isPseudoElement(node, force)
+}


Test patch:
diff --git a/tests/apply.test.js b/tests/apply.test.js
--- a/tests/apply.test.js
+++ b/tests/apply.test.js
@@ -2357,4 +2357,74 @@ crosscheck(({ stable, oxide }) => {
       `)
     })
   })
+
+  it('pseudo elements inside apply are moved outside of :is() or :has()', () => {
+    let config = {
+      darkMode: 'class',
+      content: [
+        {
+          raw: html` <div class="foo bar baz qux steve bob"></div> `,
+        },
+      ],
+    }
+
+    let input = css`
+      .foo::before {
+        @apply dark:bg-black/100;
+      }
+
+      .bar::before {
+        @apply rtl:dark:bg-black/100;
+      }
+
+      .baz::before {
+        @apply rtl:dark:hover:bg-black/100;
+      }
+
+      .qux::file-selector-button {
+        @apply rtl:dark:hover:bg-black/100;
+      }
+
+      .steve::before {
+        @apply rtl:hover:dark:bg-black/100;
+      }
+
+      .bob::file-selector-button {
+        @apply rtl:hover:dark:bg-black/100;
+      }
+
+      .foo::before {
+        @apply [:has([dir="rtl"]_&)]:hover:bg-black/100;
+      }
+
+      .bar::file-selector-button {
+        @apply [:has([dir="rtl"]_&)]:hover:bg-black/100;
+      }
+    `
+
+    return run(input, config).then((result) => {
+      expect(result.css).toMatchFormattedCss(css`
+        :is(.dark .foo)::before,
+        :is([dir='rtl'] :is(.dark .bar))::before,
+        :is([dir='rtl'] :is(.dark .baz:hover))::before {
+          background-color: #000;
+        }
+        :is([dir='rtl'] :is(.dark .qux))::file-selector-button:hover {
+          background-color: #000;
+        }
+        :is([dir='rtl'] :is(.dark .steve):hover):before {
+          background-color: #000;
+        }
+        :is([dir='rtl'] :is(.dark .bob))::file-selector-button:hover {
+          background-color: #000;
+        }
+        :has([dir='rtl'] .foo:hover):before {
+          background-color: #000;
+        }
+        :has([dir='rtl'] .bar)::file-selector-button:hover {
+          background-color: #000;
+        }
+      `)
+    })
+  })
 })
diff --git a/tests/important-selector.test.js b/tests/important-selector.test.js
--- a/tests/important-selector.test.js
+++ b/tests/important-selector.test.js
@@ -21,6 +21,7 @@ crosscheck(({ stable, oxide }) => {
             <div class="group-hover:focus-within:text-left"></div>
             <div class="rtl:active:text-center"></div>
             <div class="dark:before:underline"></div>
+            <div class="hover:[&::file-selector-button]:rtl:dark:bg-black/100"></div>
           `,
         },
       ],
@@ -155,6 +156,12 @@ crosscheck(({ stable, oxide }) => {
             text-align: right;
           }
         }
+        #app
+          :is(
+            [dir='rtl'] :is(.dark .hover\:\[\&\:\:file-selector-button\]\:rtl\:dark\:bg-black\/100)
+          )::file-selector-button:hover {
+          background-color: #000;
+        }
       `)
     })
   })