Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
Original file line number Diff line number Diff line change
@@ -1,68 +1,103 @@
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,
start: example.indexOf(candidate),
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')
})
})
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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,
Expand Down Expand Up @@ -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
}
Expand Down Expand Up @@ -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
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Should we consider that in JS backtick strings can span lines — or just not worry about that unless it comes up?

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We haven't in the past and nobody had issues with this yet (afaik at least) so I don't think we need to add the additional complexity of this (yet) 🤔


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
}