Repository: tailwindlabs/tailwindcss (2025-08-11T22:20:58Z)

Issue:
The npx @tailwindcss/upgrade command accidentally modified variables or event name, when the variables or event name look like classnames
<img width="2098" height="424" alt="Image" src="https://github.com/user-attachments/assets/93737f81-1402-4e05-a511-5c38cce9f46b" />

<img width="2502" height="302" alt="Image" src="https://github.com/user-attachments/assets/dfe36d52-5a1e-472c-8ad1-b7da58eac951" />


Discussion:
Same...
This will be addressed in the next release 👍 



Patch:
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 3971ac3ee235..bfb626528a9a 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -24,6 +24,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
 - Improve error messages when encountering invalid functional utility names ([#18568](https://github.com/tailwindlabs/tailwindcss/pull/18568))
 - Don’t output CSS objects with false or undefined in the AST ([#18571](https://github.com/tailwindlabs/tailwindcss/pull/18571))
 - Add option to disable url rewriting in `@tailwindcss/postcss` ([#18321](https://github.com/tailwindlabs/tailwindcss/pull/18321))
+- Fix false-positive migrations in `addEventListener` and JavaScript variable names ([#18718](https://github.com/tailwindlabs/tailwindcss/pull/18718))
 
 ## [4.1.11] - 2025-06-26
 
diff --git a/packages/@tailwindcss-upgrade/src/codemods/template/is-safe-migration.ts b/packages/@tailwindcss-upgrade/src/codemods/template/is-safe-migration.ts
index 7ac24d645fb3..e4ec86b2802c 100644
--- a/packages/@tailwindcss-upgrade/src/codemods/template/is-safe-migration.ts
+++ b/packages/@tailwindcss-upgrade/src/codemods/template/is-safe-migration.ts
@@ -3,7 +3,6 @@ import type { DesignSystem } from '../../../../tailwindcss/src/design-system'
 import { DefaultMap } from '../../../../tailwindcss/src/utils/default-map'
 import * as version from '../../utils/version'
 
-const QUOTES = ['"', "'", '`']
 const LOGICAL_OPERATORS = ['&&', '||', '?', '===', '==', '!=', '!==', '>', '>=', '<', '<=']
 const CONDITIONAL_TEMPLATE_SYNTAX = [
   // Vue
@@ -12,13 +11,16 @@ const CONDITIONAL_TEMPLATE_SYNTAX = [
   /v-show=['"]$/,
   /(?<!:?class)=['"]$/,
 
+  // JavaScript / TypeScript
+  /addEventListener\(['"`]$/,
+
   // Alpine
   /x-if=['"]$/,
   /x-show=['"]$/,
   /wire:[^\s]*?$/,
 ]
-const NEXT_PLACEHOLDER_PROP = /placeholder=\{?['"]$/
-const VUE_3_EMIT = /\b\$?emit\(['"]$/
+const NEXT_PLACEHOLDER_PROP = /placeholder=\{?['"`]$/
+const VUE_3_EMIT = /\b\$?emit\(['"`]$/
 
 export function isSafeMigration(
   rawCandidate: string,
@@ -138,8 +140,8 @@ export function isSafeMigration(
   }
 
   // Heuristic: Require the candidate to be inside quotes
-  let isQuoteBeforeCandidate = QUOTES.some((quote) => currentLineBeforeCandidate.includes(quote))
-  let isQuoteAfterCandidate = QUOTES.some((quote) => currentLineAfterCandidate.includes(quote))
+  let isQuoteBeforeCandidate = isMiddleOfString(currentLineBeforeCandidate)
+  let isQuoteAfterCandidate = isMiddleOfString(currentLineAfterCandidate)
   if (!isQuoteBeforeCandidate || !isQuoteAfterCandidate) {
     return false
   }
@@ -210,3 +212,38 @@ const styleBlockRanges = new DefaultMap((source: string) => {
     ranges.push(startTag, endTag)
   }
 })
+
+const BACKSLASH = 0x5c
+const DOUBLE_QUOTE = 0x22
+const SINGLE_QUOTE = 0x27
+const BACKTICK = 0x60
+
+function isMiddleOfString(line: string): boolean {
+  let currentQuote: number | null = null
+
+  for (let i = 0; i < line.length; i++) {
+    let char = line.charCodeAt(i)
+    switch (char) {
+      // Escaped character, skip the next character
+      case BACKSLASH:
+        i++
+        break
+
+      case SINGLE_QUOTE:
+      case DOUBLE_QUOTE:
+      case BACKTICK:
+        // Found matching quote, we are outside of a string
+        if (currentQuote === char) {
+          currentQuote = null
+        }
+
+        // Found a quote, we are inside a string
+        else if (currentQuote === null) {
+          currentQuote = char
+        }
+        break
+    }
+  }
+
+  return currentQuote !== null
+}


Test patch:
diff --git a/packages/@tailwindcss-upgrade/src/codemods/template/is-safe-migration.test.ts b/packages/@tailwindcss-upgrade/src/codemods/template/is-safe-migration.test.ts
index 5ade133e06fc..f6285480168f 100644
--- a/packages/@tailwindcss-upgrade/src/codemods/template/is-safe-migration.test.ts
+++ b/packages/@tailwindcss-upgrade/src/codemods/template/is-safe-migration.test.ts
@@ -1,15 +1,97 @@
 import { __unstable__loadDesignSystem } from '@tailwindcss/node'
-import { expect, test, vi } from 'vitest'
+import { describe, expect, test, vi } from 'vitest'
 import * as versions from '../../utils/version'
 import { migrateCandidate } from './migrate'
 vi.spyOn(versions, 'isMajor').mockReturnValue(true)
 
-test('does not replace classes in invalid positions', async () => {
+describe('is-safe-migration', async () => {
   let designSystem = await __unstable__loadDesignSystem('@import "tailwindcss";', {
     base: __dirname,
   })
 
-  async function shouldNotReplace(example: string, candidate = '!border') {
+  test.each([
+    [`let notBorder = !border    \n`, '!border'],
+    [`{ "foo": !border.something + ""}\n`, '!border'],
+    [`<div v-if="something && !border"></div>\n`, '!border'],
+    [`<div v-else-if="something && !border"></div>\n`, '!border'],
+    [`<div v-show="something && !border"></div>\n`, '!border'],
+    [`<div v-if="!border || !border"></div>\n`, '!border'],
+    [`<div v-else-if="!border || !border"></div>\n`, '!border'],
+    [`<div v-show="!border || !border"></div>\n`, '!border'],
+    [`<div v-if="!border"></div>\n`, '!border'],
+    [`<div v-else-if="!border"></div>\n`, '!border'],
+    [`<div v-show="!border"></div>\n`, '!border'],
+    [`<div x-if="!border"></div>\n`, '!border'],
+
+    [`let notShadow = shadow    \n`, 'shadow'],
+    [`{ "foo": shadow.something + ""}\n`, 'shadow'],
+    [`<div v-if="something && shadow"></div>\n`, 'shadow'],
+    [`<div v-else-if="something && shadow"></div>\n`, 'shadow'],
+    [`<div v-show="something && shadow"></div>\n`, 'shadow'],
+    [`<div v-if="shadow || shadow"></div>\n`, 'shadow'],
+    [`<div v-else-if="shadow || shadow"></div>\n`, 'shadow'],
+    [`<div v-show="shadow || shadow"></div>\n`, 'shadow'],
+    [`<div v-if="shadow"></div>\n`, 'shadow'],
+    [`<div v-else-if="shadow"></div>\n`, 'shadow'],
+    [`<div v-show="shadow"></div>\n`, 'shadow'],
+    [`<div x-if="shadow"></div>\n`, 'shadow'],
+    [`<div style={{filter: 'drop-shadow(30px 10px 4px #4444dd)'}}/>\n`, 'shadow'],
+
+    // Next.js Image placeholder cases
+    [`<Image placeholder="blur" src="/image.jpg" />`, 'blur'],
+    [`<Image placeholder={'blur'} src="/image.jpg" />`, 'blur'],
+    [`<Image placeholder={blur} src="/image.jpg" />`, 'blur'],
+
+    // https://github.com/tailwindlabs/tailwindcss/issues/17974
+    ['<div v-if="!duration">', '!duration'],
+    ['<div :active="!duration">', '!duration'],
+    ['<div :active="!visible">', '!visible'],
+
+    // Alpine/Livewire wire:…
+    ['<x-input.number required="foo" wire:model.blur="coins" />', 'blur'],
+
+    // Vue 3 events
+    [`emit('blur', props.modelValue)\n`, 'blur'],
+    [`$emit('blur', props.modelValue)\n`, 'blur'],
+
+    // JavaScript / TypeScript
+    [`document.addEventListener('blur',handleBlur)`, 'blur'],
+    [`document.addEventListener('blur', handleBlur)`, 'blur'],
+
+    [`function foo({ outline = true })`, 'outline'],
+    [`function foo({ before = false, outline = true })`, 'outline'],
+    [`function foo({before=false,outline=true })`, 'outline'],
+    [`function foo({outline=true })`, 'outline'],
+    // https://github.com/tailwindlabs/tailwindcss/issues/18675
+    [
+      // With default value
+      `function foo({ size = "1.25rem", digit, outline = true, textClass = "", className = "" })`,
+      'outline',
+    ],
+    [
+      // Without default value
+      `function foo({ size = "1.25rem", digit, outline, textClass = "", className = "" })`,
+      'outline',
+    ],
+    [
+      // As the last argument
+      `function foo({ size = "1.25rem", digit, outline })`,
+      'outline',
+    ],
+    [
+      // As the last argument, but there is techinically another `"` on the same line
+      `function foo({ size = "1.25rem", digit, outline }): { return "foo" }`,
+      'outline',
+    ],
+    [
+      // Tricky quote balancing
+      `function foo({ before = "'", outline, after = "'" }): { return "foo" }`,
+      'outline',
+    ],
+
+    [`function foo(blur, foo)`, 'blur'],
+    [`function foo(blur,foo)`, 'blur'],
+  ])('does not replace classes in invalid positions #%#', async (example, candidate) => {
     expect(
       await migrateCandidate(designSystem, {}, candidate, {
         contents: example,
@@ -17,52 +99,5 @@ test('does not replace classes in invalid positions', async () => {
         end: example.indexOf(candidate) + candidate.length,
       }),
     ).toEqual(candidate)
-  }
-
-  await shouldNotReplace(`let notBorder = !border    \n`)
-  await shouldNotReplace(`{ "foo": !border.something + ""}\n`)
-  await shouldNotReplace(`<div v-if="something && !border"></div>\n`)
-  await shouldNotReplace(`<div v-else-if="something && !border"></div>\n`)
-  await shouldNotReplace(`<div v-show="something && !border"></div>\n`)
-  await shouldNotReplace(`<div v-if="!border || !border"></div>\n`)
-  await shouldNotReplace(`<div v-else-if="!border || !border"></div>\n`)
-  await shouldNotReplace(`<div v-show="!border || !border"></div>\n`)
-  await shouldNotReplace(`<div v-if="!border"></div>\n`)
-  await shouldNotReplace(`<div v-else-if="!border"></div>\n`)
-  await shouldNotReplace(`<div v-show="!border"></div>\n`)
-  await shouldNotReplace(`<div x-if="!border"></div>\n`)
-
-  await shouldNotReplace(`let notShadow = shadow    \n`, 'shadow')
-  await shouldNotReplace(`{ "foo": shadow.something + ""}\n`, 'shadow')
-  await shouldNotReplace(`<div v-if="something && shadow"></div>\n`, 'shadow')
-  await shouldNotReplace(`<div v-else-if="something && shadow"></div>\n`, 'shadow')
-  await shouldNotReplace(`<div v-show="something && shadow"></div>\n`, 'shadow')
-  await shouldNotReplace(`<div v-if="shadow || shadow"></div>\n`, 'shadow')
-  await shouldNotReplace(`<div v-else-if="shadow || shadow"></div>\n`, 'shadow')
-  await shouldNotReplace(`<div v-show="shadow || shadow"></div>\n`, 'shadow')
-  await shouldNotReplace(`<div v-if="shadow"></div>\n`, 'shadow')
-  await shouldNotReplace(`<div v-else-if="shadow"></div>\n`, 'shadow')
-  await shouldNotReplace(`<div v-show="shadow"></div>\n`, 'shadow')
-  await shouldNotReplace(`<div x-if="shadow"></div>\n`, 'shadow')
-  await shouldNotReplace(
-    `<div style={{filter: 'drop-shadow(30px 10px 4px #4444dd)'}}/>\n`,
-    'shadow',
-  )
-
-  // Next.js Image placeholder cases
-  await shouldNotReplace(`<Image placeholder="blur" src="/image.jpg" />`, 'blur')
-  await shouldNotReplace(`<Image placeholder={'blur'} src="/image.jpg" />`, 'blur')
-  await shouldNotReplace(`<Image placeholder={blur} src="/image.jpg" />`, 'blur')
-
-  // https://github.com/tailwindlabs/tailwindcss/issues/17974
-  await shouldNotReplace('<div v-if="!duration">', '!duration')
-  await shouldNotReplace('<div :active="!duration">', '!duration')
-  await shouldNotReplace('<div :active="!visible">', '!visible')
-
-  // Alpine/Livewire wire:…
-  await shouldNotReplace('<x-input.number required="foo" wire:model.blur="coins" />', 'blur')
-
-  // Vue 3 events
-  await shouldNotReplace(`emit('blur', props.modelValue)\n`, 'blur')
-  await shouldNotReplace(`$emit('blur', props.modelValue)\n`, 'blur')
+  })
 })