Repository: tailwindlabs/tailwindcss (TypeScript)

Issue:
Bug: some text content can trigger multiple Tailwind builds in the same output css
<!-- 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?**

v3.0.16 (also saw on v3.0.15)

**What build tool (or framework if it abstracts the build tool) are you using?**

Just Tailwind.

**What version of Node.js are you using?**

v16.13.0

**What browser are you using?**

N/A

**What operating system are you using?**

macOS

**Reproduction URL**

https://github.com/matthewcc/tailwind3-duplicates

**Describe your issue**

Modifiers (such as pseudo-classes) followed by an asterisk appear to trigger additional instances of "tailwindcss v3.x.x" being inserted into the outputted css file.

In our case, we had things like `sm:*` and `focus:*` as text included in a Storybook mdx file as documentation. This mdx file also has jsx with actual Tailwind classes, so we need it added to the `content` property in the Tailwind config. This triggered some extra builds (located in the same css output file), and aside from just extra lines some of our own styles lost priority to re-inserted default Tailwind styles. There are additional notes in the Read Me in the reproduction link.

We've changed the text that triggered this issue in our files in the meantime and it's possibly an edge case, but we wanted to share it.

Big fans of Tailwind, thanks for your work.


Hints:

Patch:
diff --git a/src/lib/expandTailwindAtRules.js b/src/lib/expandTailwindAtRules.js
--- a/src/lib/expandTailwindAtRules.js
+++ b/src/lib/expandTailwindAtRules.js
@@ -158,7 +158,7 @@ export default function expandTailwindAtRules(context) {
     // ---
 
     // Find potential rules in changed files
-    let candidates = new Set(['*'])
+    let candidates = new Set([sharedState.NOT_ON_DEMAND])
     let seen = new Set()
 
     env.DEBUG && console.time('Reading changed files')
diff --git a/src/lib/generateRules.js b/src/lib/generateRules.js
--- a/src/lib/generateRules.js
+++ b/src/lib/generateRules.js
@@ -5,6 +5,7 @@ import isPlainObject from '../util/isPlainObject'
 import prefixSelector from '../util/prefixSelector'
 import { updateAllClasses } from '../util/pluginUtils'
 import log from '../util/log'
+import * as sharedState from './sharedState'
 import { formatVariantSelector, finalizeSelector } from '../util/formatVariantSelector'
 import { asClass } from '../util/nameClass'
 import { normalize } from '../util/dataTypes'
@@ -382,6 +383,10 @@ function* resolveMatchedPlugins(classCandidate, context) {
 }
 
 function splitWithSeparator(input, separator) {
+  if (input === sharedState.NOT_ON_DEMAND) {
+    return [sharedState.NOT_ON_DEMAND]
+  }
+
   return input.split(new RegExp(`\\${separator}(?![^[]*\\])`, 'g'))
 }
 
diff --git a/src/lib/setupContextUtils.js b/src/lib/setupContextUtils.js
--- a/src/lib/setupContextUtils.js
+++ b/src/lib/setupContextUtils.js
@@ -138,7 +138,7 @@ function withIdentifiers(styles) {
 
     // If this isn't "on-demandable", assign it a universal candidate to always include it.
     if (containsNonOnDemandableSelectors) {
-      candidates.unshift('*')
+      candidates.unshift(sharedState.NOT_ON_DEMAND)
     }
 
     // However, it could be that it also contains "on-demandable" candidates.
@@ -163,8 +163,8 @@ function buildPluginApi(tailwindConfig, context, { variantList, variantMap, offs
   }
 
   function prefixIdentifier(identifier, options) {
-    if (identifier === '*') {
-      return '*'
+    if (identifier === sharedState.NOT_ON_DEMAND) {
+      return sharedState.NOT_ON_DEMAND
     }
 
     if (!options.respectPrefix) {
diff --git a/src/lib/sharedState.js b/src/lib/sharedState.js
--- a/src/lib/sharedState.js
+++ b/src/lib/sharedState.js
@@ -5,6 +5,7 @@ export const env = {
 export const contextMap = new Map()
 export const configContextMap = new Map()
 export const contextSourcesMap = new Map()
+export const NOT_ON_DEMAND = new String('*')
 
 export function resolveDebug(debug) {
   if (debug === undefined) {


Test patch:
diff --git a/tests/basic-usage.test.js b/tests/basic-usage.test.js
--- a/tests/basic-usage.test.js
+++ b/tests/basic-usage.test.js
@@ -1,7 +1,7 @@
 import fs from 'fs'
 import path from 'path'
 
-import { html, run, css } from './util/run'
+import { html, run, css, defaults } from './util/run'
 
 test('basic usage', () => {
   let config = {
@@ -188,3 +188,64 @@ it('can scan extremely long classes without crashing', () => {
     expect(result.css).toMatchFormattedCss(css``)
   })
 })
+
+it('does not produce duplicate output when seeing variants preceding a wildcard (*)', () => {
+  let config = {
+    content: [{ raw: html`underline focus:*` }],
+    corePlugins: { preflight: false },
+  }
+
+  let input = css`
+    @tailwind base;
+    @tailwind components;
+    @tailwind utilities;
+
+    * {
+      color: red;
+    }
+
+    .combined,
+    * {
+      text-align: center;
+    }
+
+    @layer base {
+      * {
+        color: blue;
+      }
+
+      .combined,
+      * {
+        color: red;
+      }
+    }
+  `
+
+  return run(input, config).then((result) => {
+    expect(result.css).toMatchFormattedCss(css`
+      * {
+        color: blue;
+      }
+
+      .combined,
+      * {
+        color: red;
+      }
+
+      ${defaults}
+
+      .underline {
+        text-decoration-line: underline;
+      }
+
+      * {
+        color: red;
+      }
+
+      .combined,
+      * {
+        text-align: center;
+      }
+    `)
+  })
+})