Skip to content

Fix bug replacing modifier variable shorthand syntax underscores #17889

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

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