Repository: tailwindlabs/tailwindcss

Problem statement:
Tailwind compiler crashes when you try to `[--box-shadow:theme('boxShadow.doesNotExist')]`
**What version of Tailwind CSS are you using?**

v3.1.8

**What build tool (or framework if it abstracts the build tool) are you using?**

vite 3.0.9, postcss 8.4.14 postcss-cli 8.3.1

**What version of Node.js are you using?**

v16.0.0

**What browser are you using?**

Chrome

**What operating system are you using?**

PopOS, Ubuntu

**Reproduction URL**

https://stackblitz.com/edit/vitejs-vite-zy2efk?file=src/App.jsx

**Describe your issue**

I was thinkering with tailwind to find ways of modifying css variables with classes. Came up with something like

```javascript
  <button className="[--box-shadow:theme('boxShadow.sm')] bg-white p-5">
    Button
  </button>
```

But if I change the `theme` to something that will throw a runtime error:

```javascript
  <button className="[--box-shadow:theme('boxShadow.doesNotExist')] bg-white p-5">
    Button
  </button>
```

Still crashes if I change it back to:

```javascript
  <button className="[--box-shadow:theme('boxShadow.sm')] bg-white p-5">
    Button
  </button>
```

Well, maybe the Tailwind compiler crashes and can't recover?

It's a bug, I think!?




Hints:

Patch:
diff --git a/src/lib/evaluateTailwindFunctions.js b/src/lib/evaluateTailwindFunctions.js
--- a/src/lib/evaluateTailwindFunctions.js
+++ b/src/lib/evaluateTailwindFunctions.js
@@ -7,6 +7,7 @@ import buildMediaQuery from '../util/buildMediaQuery'
 import { toPath } from '../util/toPath'
 import { withAlphaValue } from '../util/withAlphaVariable'
 import { parseColorFormat } from '../util/pluginUtils'
+import log from '../util/log'
 
 function isObject(input) {
   return typeof input === 'object' && input !== null
@@ -196,7 +197,9 @@ function resolvePath(config, path, defaultValue) {
   return results.find((result) => result.isValid) ?? results[0]
 }
 
-export default function ({ tailwindConfig: config }) {
+export default function (context) {
+  let config = context.tailwindConfig
+
   let functions = {
     theme: (node, path, ...defaultValue) => {
       let { isValid, value, error, alpha } = resolvePath(
@@ -206,6 +209,24 @@ export default function ({ tailwindConfig: config }) {
       )
 
       if (!isValid) {
+        let parentNode = node.parent
+        let candidate = parentNode?.raws.tailwind?.candidate
+
+        if (parentNode && candidate !== undefined) {
+          // Remove this utility from any caches
+          context.markInvalidUtilityNode(parentNode)
+
+          // Remove the CSS node from the markup
+          parentNode.remove()
+
+          // Show a warning
+          log.warn('invalid-theme-key-in-class', [
+            `The utility \`${candidate}\` contains an invalid theme value and was not generated.`,
+          ])
+
+          return
+        }
+
         throw node.error(error)
       }
 
diff --git a/src/lib/setupContextUtils.js b/src/lib/setupContextUtils.js
--- a/src/lib/setupContextUtils.js
+++ b/src/lib/setupContextUtils.js
@@ -856,6 +856,52 @@ function registerPlugins(plugins, context) {
   }
 }
 
+/**
+ * Mark as class as retroactively invalid
+ *
+ *
+ * @param {string} candidate
+ */
+function markInvalidUtilityCandidate(context, candidate) {
+  if (!context.classCache.has(candidate)) {
+    return
+  }
+
+  // Mark this as not being a real utility
+  context.notClassCache.add(candidate)
+
+  // Remove it from any candidate-specific caches
+  context.classCache.delete(candidate)
+  context.applyClassCache.delete(candidate)
+  context.candidateRuleMap.delete(candidate)
+  context.candidateRuleCache.delete(candidate)
+
+  // Ensure the stylesheet gets rebuilt
+  context.stylesheetCache = null
+}
+
+/**
+ * Mark as class as retroactively invalid
+ *
+ * @param {import('postcss').Node} node
+ */
+function markInvalidUtilityNode(context, node) {
+  let candidate = node.raws.tailwind.candidate
+
+  if (!candidate) {
+    return
+  }
+
+  for (const entry of context.ruleCache) {
+    if (entry[1].raws.tailwind.candidate === candidate) {
+      context.ruleCache.delete(entry)
+      // context.postCssNodeCache.delete(node)
+    }
+  }
+
+  markInvalidUtilityCandidate(context, candidate)
+}
+
 export function createContext(tailwindConfig, changedContent = [], root = postcss.root()) {
   let context = {
     disposables: [],
@@ -870,6 +916,9 @@ export function createContext(tailwindConfig, changedContent = [], root = postcs
     changedContent: changedContent,
     variantMap: new Map(),
     stylesheetCache: null,
+
+    markInvalidUtilityCandidate: (candidate) => markInvalidUtilityCandidate(context, candidate),
+    markInvalidUtilityNode: (node) => markInvalidUtilityNode(context, node),
   }
 
   let resolvedPlugins = resolvePlugins(context, root)


Test patch:
diff --git a/tests/evaluateTailwindFunctions.test.js b/tests/evaluateTailwindFunctions.test.js
--- a/tests/evaluateTailwindFunctions.test.js
+++ b/tests/evaluateTailwindFunctions.test.js
@@ -1,14 +1,18 @@
+import fs from 'fs'
+import path from 'path'
 import postcss from 'postcss'
 import plugin from '../src/lib/evaluateTailwindFunctions'
-import tailwind from '../src/index'
-import { css } from './util/run'
+import { run as runFull, css, html } from './util/run'
 
 function run(input, opts = {}) {
-  return postcss([plugin({ tailwindConfig: opts })]).process(input, { from: undefined })
-}
-
-function runFull(input, config) {
-  return postcss([tailwind(config)]).process(input, { from: undefined })
+  return postcss([
+    plugin({
+      tailwindConfig: opts,
+      markInvalidUtilityNode() {
+        // no op
+      },
+    }),
+  ]).process(input, { from: undefined })
 }
 
 test('it looks up values in the theme using dot notation', () => {
@@ -1222,3 +1226,62 @@ it('can find values with slashes in the theme key while still allowing for alpha
     `)
   })
 })
+
+describe('context dependent', () => {
+  let configPath = path.resolve(__dirname, './evaluate-tailwind-functions.tailwind.config.js')
+  let filePath = path.resolve(__dirname, './evaluate-tailwind-functions.test.html')
+  let config = {
+    content: [filePath],
+    corePlugins: { preflight: false },
+  }
+
+  // Rebuild the config file for each test
+  beforeEach(() => fs.promises.writeFile(configPath, `module.exports = ${JSON.stringify(config)};`))
+  afterEach(() => fs.promises.unlink(configPath))
+
+  let warn
+
+  beforeEach(() => {
+    warn = jest.spyOn(require('../src/util/log').default, 'warn')
+  })
+
+  afterEach(() => warn.mockClear())
+
+  it('should not generate when theme fn doesnt resolve', async () => {
+    await fs.promises.writeFile(
+      filePath,
+      html`
+        <div class="underline [--box-shadow:theme('boxShadow.doesnotexist')]"></div>
+        <div class="bg-[theme('boxShadow.doesnotexist')]"></div>
+      `
+    )
+
+    // TODO: We need a way to reuse the context in our test suite without requiring writing to files
+    // It should be an explicit thing tho — like we create a context and pass it in or something
+    let result = await runFull('@tailwind utilities', configPath)
+
+    // 1. On first run it should work because it's been removed from the class cache
+    expect(result.css).toMatchCss(css`
+      .underline {
+        text-decoration-line: underline;
+      }
+    `)
+
+    // 2. But we get a warning in the console
+    expect(warn).toHaveBeenCalledTimes(1)
+    expect(warn.mock.calls.map((x) => x[0])).toEqual(['invalid-theme-key-in-class'])
+
+    // 3. The second run should work fine because it's been removed from the class cache
+    result = await runFull('@tailwind utilities', configPath)
+
+    expect(result.css).toMatchCss(css`
+      .underline {
+        text-decoration-line: underline;
+      }
+    `)
+
+    // 4. But we've not received any further logs about it
+    expect(warn).toHaveBeenCalledTimes(1)
+    expect(warn.mock.calls.map((x) => x[0])).toEqual(['invalid-theme-key-in-class'])
+  })
+})