Skip to content
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- Ensure negative arbitrary `scale` values generate negative values ([#17831](https://github.com/tailwindlabs/tailwindcss/pull/17831))
- Fix HAML extraction with embedded Ruby ([#17846](https://github.com/tailwindlabs/tailwindcss/pull/17846))
- Don't scan files for utilities when using `@reference` ([#17836](https://github.com/tailwindlabs/tailwindcss/pull/17836))
- Fix incorrectly replacing `_` with ` ` in arbitrary modifier shorthand `bg-red-500/(--my_opacity)` ([#17889](https://github.com/tailwindlabs/tailwindcss/pull/17889))

## [4.1.5] - 2025-04-30

Expand Down
151 changes: 151 additions & 0 deletions packages/tailwindcss/src/candidate.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1087,6 +1087,7 @@ it('should parse a utility with an implicit variable as the modifier using the s
let utilities = new Utilities()
utilities.functional('bg', () => [])

// Standard case (no underscores)
expect(run('bg-red-500/(--value)', { utilities })).toMatchInlineSnapshot(`
[
{
Expand All @@ -1107,6 +1108,156 @@ it('should parse a utility with an implicit variable as the modifier using the s
},
]
`)

// Should preserve underscores
expect(run('bg-red-500/(--with_underscore)', { utilities })).toMatchInlineSnapshot(`
[
{
"important": false,
"kind": "functional",
"modifier": {
"kind": "arbitrary",
"value": "var(--with_underscore)",
},
"raw": "bg-red-500/(--with_underscore)",
"root": "bg",
"value": {
"fraction": null,
"kind": "named",
"value": "red-500",
},
"variants": [],
},
]
`)

// Should remove underscores in fallback values
expect(run('bg-red-500/(--with_underscore,fallback_value)', { utilities }))
.toMatchInlineSnapshot(`
[
{
"important": false,
"kind": "functional",
"modifier": {
"kind": "arbitrary",
"value": "var(--with_underscore,fallback value)",
},
"raw": "bg-red-500/(--with_underscore,fallback_value)",
"root": "bg",
"value": {
"fraction": null,
"kind": "named",
"value": "red-500",
},
"variants": [],
},
]
`)

// Should keep underscores in the CSS variable itself, but remove underscores
// in fallback values
expect(run('bg-(--a_b,c_d_var(--e_f,g_h))/(--i_j,k_l_var(--m_n,o_p))', { utilities }))
.toMatchInlineSnapshot(`
[
{
"important": false,
"kind": "functional",
"modifier": {
"kind": "arbitrary",
"value": "var(--i_j,k l var(--m_n,o p))",
},
"raw": "bg-(--a_b,c_d_var(--e_f,g_h))/(--i_j,k_l_var(--m_n,o_p))",
"root": "bg",
"value": {
"dataType": null,
"kind": "arbitrary",
"value": "var(--a_b,c d var(--e_f,g h))",
},
"variants": [],
},
]
`)
})

it('should not parse an invalid arbitrary shorthand modifier', () => {
let utilities = new Utilities()
utilities.functional('bg', () => [])

// Completely empty
expect(run('bg-red-500/()', { utilities })).toMatchInlineSnapshot(`[]`)

// Invalid due to leading spaces
expect(run('bg-red-500/(_--)', { utilities })).toMatchInlineSnapshot(`[]`)
expect(run('bg-red-500/(_--x)', { utilities })).toMatchInlineSnapshot(`[]`)

// Invalid due to leading spaces
expect(run('bg-red-500/(_--)', { utilities })).toMatchInlineSnapshot(`[]`)
expect(run('bg-red-500/(_--x)', { utilities })).toMatchInlineSnapshot(`[]`)

// Invalid due to top-level `;` or `}` characters
expect(run('bg-red-500/(--x;--y)', { utilities })).toMatchInlineSnapshot(`[]`)
expect(run('bg-red-500/(--x:{foo:bar})', { utilities })).toMatchInlineSnapshot(`[]`)

// Valid, but ensuring that we didn't make an off-by-one error
expect(run('bg-red-500/(--x)', { utilities })).toMatchInlineSnapshot(`
[
{
"important": false,
"kind": "functional",
"modifier": {
"kind": "arbitrary",
"value": "var(--x)",
},
"raw": "bg-red-500/(--x)",
"root": "bg",
"value": {
"fraction": null,
"kind": "named",
"value": "red-500",
},
"variants": [],
},
]
`)
})

it('should not parse an invalid arbitrary shorthand value', () => {
let utilities = new Utilities()
utilities.functional('bg', () => [])

// Completely empty
expect(run('bg-()', { utilities })).toMatchInlineSnapshot(`[]`)

// Invalid due to leading spaces
expect(run('bg-(_--)', { utilities })).toMatchInlineSnapshot(`[]`)
expect(run('bg-(_--x)', { utilities })).toMatchInlineSnapshot(`[]`)

// Invalid due to leading spaces
expect(run('bg-(_--)', { utilities })).toMatchInlineSnapshot(`[]`)
expect(run('bg-(_--x)', { utilities })).toMatchInlineSnapshot(`[]`)

// Invalid due to top-level `;` or `}` characters
expect(run('bg-(--x;--y)', { utilities })).toMatchInlineSnapshot(`[]`)
expect(run('bg-(--x:{foo:bar})', { utilities })).toMatchInlineSnapshot(`[]`)

// Valid, but ensuring that we didn't make an off-by-one error
expect(run('bg-(--x)', { utilities })).toMatchInlineSnapshot(`
[
{
"important": false,
"kind": "functional",
"modifier": null,
"raw": "bg-(--x)",
"root": "bg",
"value": {
"dataType": null,
"kind": "arbitrary",
"value": "var(--x)",
},
"variants": [],
},
]
`)
})

it('should not parse a utility with an implicit invalid variable as the modifier using the shorthand', () => {
Expand Down
28 changes: 17 additions & 11 deletions packages/tailwindcss/src/candidate.ts
Original file line number Diff line number Diff line change
Expand Up @@ -411,7 +411,10 @@ export function* parseCandidate(input: string, designSystem: DesignSystem): Iter

// An arbitrary value with `(…)` should always start with `--` since it
// represents a CSS variable.
if (value[0] !== '-' && value[1] !== '-') return
if (value[0] !== '-' || value[1] !== '-') return

// Values can't contain `;` or `}` characters at the top-level.
if (!isValidArbitrary(value)) return

roots = [[root, dataType === null ? `[var(${value})]` : `[${dataType}:var(${value})]`]]
}
Expand Down Expand Up @@ -523,21 +526,24 @@ function parseModifier(modifier: string): CandidateModifier | null {
}

if (modifier[0] === '(' && modifier[modifier.length - 1] === ')') {
let arbitraryValue = decodeArbitraryValue(modifier.slice(1, -1))
// Drop the `(` and `)` characters
modifier = modifier.slice(1, -1)

// A modifier with `(…)` should always start with `--` since it
// represents a CSS variable.
if (modifier[0] !== '-' || modifier[1] !== '-') return null

// Values can't contain `;` or `}` characters at the top-level.
if (!isValidArbitrary(arbitraryValue)) return null
if (!isValidArbitrary(modifier)) return null

// Empty arbitrary values are invalid. E.g.: `data-():`
// ^^
if (arbitraryValue.length === 0 || arbitraryValue.trim().length === 0) return null
// Wrap the value in `var(…)` to ensure that it is a valid CSS variable.
modifier = `var(${modifier})`

// Arbitrary values must start with `--` since it represents a CSS variable.
if (arbitraryValue[0] !== '-' && arbitraryValue[1] !== '-') return null
let arbitraryValue = decodeArbitraryValue(modifier)

return {
kind: 'arbitrary',
value: `var(${arbitraryValue})`,
value: arbitraryValue,
}
}

Expand Down Expand Up @@ -679,7 +685,7 @@ export function parseVariant(variant: string, designSystem: DesignSystem): Varia
if (arbitraryValue.length === 0 || arbitraryValue.trim().length === 0) return null

// Arbitrary values must start with `--` since it represents a CSS variable.
if (arbitraryValue[0] !== '-' && arbitraryValue[1] !== '-') return null
if (arbitraryValue[0] !== '-' || arbitraryValue[1] !== '-') return null

return {
kind: 'functional',
Expand Down Expand Up @@ -1030,7 +1036,7 @@ function recursivelyEscapeUnderscores(ast: ValueParser.ValueAstNode[]) {
case 'word': {
// Dashed idents and variables `var(--my-var)` and `--my-var` should not
// have underscores escaped
if (node.value[0] !== '-' && node.value[1] !== '-') {
if (node.value[0] !== '-' || node.value[1] !== '-') {
node.value = escapeUnderscore(node.value)
}
break
Expand Down