Repository: tailwindlabs/tailwindcss (TypeScript)

Issue:
Nested and grouped scss selector doesn't work with responsive utility variants
<!-- 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.0.24`

**What build tool (or framework if it abstracts the build tool) are you using?**
```
laravel-mix: 6.0.43 (abstracts webpack with:)
webpack: 5.60.0
postcss-loader: 6.2.0
sass-loader: 12.6.0
postcss: 8.4.12
sass: 1.51.0
```

**What version of Node.js are you using?**
`v16.14.1`

**What browser are you using?**
Chrome, Firefox

**What operating system are you using?**
macOS

**Reproduction URL**

[Repro on GitHub](https://github.com/mimamuh/tailwind-issue-with-nested-scss-selectors)

**Describe your issue**

I have a _SCSS_ file `./src/main.scss` with a utility rule added to the `@components` layer using _Tailwind_ with _SASS_:

```scss
@layer components {
  .base {
    /* NOTE: These grouped and nested rules causes the 
    bug when the `base` utility is combined with a breakpoint 
    like `md:base` */
    .foo,
    .bar {
      @apply bg-rose-600;
    }
  }
}
```

Then there is some _HTML_ `./index.html` using the new utilities together with a responsive utility variant `sm:`:

```html
<!-- This div uses the md:base here -->
<div class="flex gap-4 md:base">
  <!-- And the foo should render something red -->
  <button class="p-2 border-2 border-zinc-600 foo">Should be red</button>
  <!-- And also bar should render something red -->
  <button class="p-2 border-2 border-zinc-600 bar">Should be red</button>
  <button class="p-2 border-2 border-zinc-600">As is</button>
</div>
```

As `.foo, .bar` is grouped, the generated _CSS_ of _Tailwind_ is this:

```css
@media (min-width: 768px) {
  .md\:base .foo,
  .base .bar {
    --tw-bg-opacity: 1;
    background-color: rgb(225 29 72 / var(--tw-bg-opacity));
  }
}
```

Instead of this:

```css
@media (min-width: 768px) {
  .md\:base .foo,
  .md\:base .bar {
    --tw-bg-opacity: 1;
    background-color: rgb(225 29 72 / var(--tw-bg-opacity));
  }
}
```

**It basically applies the breakpoint only to the first grouped and nested utility combo `.base .foo`, but not to the second `.base .bar`.**


Hints:

Patch:
diff --git a/src/util/formatVariantSelector.js b/src/util/formatVariantSelector.js
--- a/src/util/formatVariantSelector.js
+++ b/src/util/formatVariantSelector.js
@@ -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)
+    }
+  })
 
-  // 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
+  // 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)
       }
 
-      let pseudoElements = collectPseudoElements(selector)
-      if (pseudoElements.length > 0) {
-        selector.nodes.push(pseudoElements.sort(sortSelector))
+      if (node?.nodes) {
+        nodes.push(...collectPseudoElements(node))
       }
+    }
+
+    return nodes
+  }
 
-      return selector
+  // Remove unnecessary pseudo selectors that we used as placeholders
+  ast.each((selector) => {
+    selector.walkPseudos((p) => {
+      if (selectorFunctions.has(p.value)) {
+        p.replaceWith(p.nodes)
+      }
     })
-  }).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


Test patch:
diff --git a/tests/variants.test.js b/tests/variants.test.js
--- a/tests/variants.test.js
+++ b/tests/variants.test.js
@@ -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;
+        }
+      }
+    `)
+  })
+})