Ensure escaped theme variables are handled correctly (#16064)

This PR ensures that escaped theme variables are properly handled. We do
this by moving the `escape`/`unescape` responsibility back into the main
tailwindcss entrypoint that reads and writes from the CSS and making
sure that _all internal state of the `Theme` class are unescaped
classes.

However, due to us accidentally shipping the part where a dot in the
theme variable would translate to an underscore in CSS already, this
logic is going to stay as-is for now.

Here's an example test that visualizes the new changes:

```ts
expect(
  await compileCss(
    css`
      @theme {
        --spacing-*: initial;
        --spacing-1\.5: 2.5rem;
        --spacing-foo\/bar: 3rem;
      }
      @tailwind utilities;
    `,
    ['m-1.5', 'm-foo/bar'],
  ),
).toMatchInlineSnapshot(`
  ":root, :host {
    --spacing-1\.5: 2.5rem;
    --spacing-foo\\/bar: 3rem;
  }

  .m-1\\.5 {
    margin: var(--spacing-1\.5);
  }

  .m-foo\\/bar {
    margin: var(--spacing-foo\\/bar);
  }"
`)
```

## Test plan

- Added a unit test
- Ensure this works end-to-end using the Vite playground:
   
<img width="1016" alt="Screenshot 2025-01-30 at 14 51 05"
src="https://github.com/user-attachments/assets/463c6fd5-793f-4ecc-86d2-5ad40bbb3e74"
/>
This commit is contained in:
Philipp Spiess 2025-01-31 15:20:18 +01:00 • committed by GitHub
parent 3aa0e494bf
commit 60e61950b9
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
5 changed files with 62 additions and 13 deletions

View file

@ -10,6 +10,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
### Fixed
- Only generate positive `grid-cols-*` and `grid-rows-*` utilities ([#16020](https://github.com/tailwindlabs/tailwindcss/pull/16020))
- Ensure escaped theme variables are handled correctly ([#16064](https://github.com/tailwindlabs/tailwindcss/pull/16064))
- Ensure we process Tailwind CSS features when only using `@reference` or `@variant` ([#16057](https://github.com/tailwindlabs/tailwindcss/pull/16057))
- Refactor gradient implementation to work around [prettier/prettier#17058](https://github.com/prettier/prettier/issues/17058) ([#16072](https://github.com/tailwindlabs/tailwindcss/pull/16072))
- Vite: Ensure hot-reloading works with SolidStart setups ([#16052](https://github.com/tailwindlabs/tailwindcss/pull/16052))

View file

@ -1,6 +1,5 @@
import type { DesignSystem } from '../design-system'
import { ThemeOptions } from '../theme'
import { escape } from '../utils/escape'
import type { ResolvedConfig } from './config/types'
function resolveThemeValue(value: unknown, subValue: string | null = null): string | null {
@ -55,7 +54,7 @@ export function applyConfigToTheme(
if (!name) continue
designSystem.theme.add(
`--${escape(name)}`,
`--${name}`,
'' + value,
ThemeOptions.INLINE | ThemeOptions.REFERENCE | ThemeOptions.DEFAULT,
)

View file

@ -152,6 +152,49 @@ describe('compiling CSS', () => {
`)
})
test('unescapes theme variables and handles dots as underscore', async () => {
expect(
await compileCss(
css`
@theme {
--spacing-*: initial;
--spacing-1\.5: 1.5px;
--spacing-2_5: 2.5px;
--spacing-3\.5: 3.5px;
--spacing-3_5: 3.5px;
--spacing-foo\/bar: 3rem;
}
@tailwind utilities;
`,
['m-1.5', 'm-2.5', 'm-2_5', 'm-3.5', 'm-foo/bar'],
),
).toMatchInlineSnapshot(`
":root, :host {
--spacing-1\\.5: 1.5px;
--spacing-2_5: 2.5px;
--spacing-3\\.5: 3.5px;
--spacing-3_5: 3.5px;
--spacing-foo\\/bar: 3rem;
}
.m-1\\.5 {
margin: var(--spacing-1\\.5);
}
.m-2\\.5, .m-2_5 {
margin: var(--spacing-2_5);
}
.m-3\\.5 {
margin: var(--spacing-3\\.5);
}
.m-foo\\/bar {
margin: var(--spacing-foo\\/bar);
}"
`)
})
test('adds vendor prefixes', async () => {
expect(
await compileCss(

View file

@ -27,6 +27,7 @@ import * as CSS from './css-parser'
import { buildDesignSystem, type DesignSystem } from './design-system'
import { Theme, ThemeOptions } from './theme'
import { createCssUtility } from './utilities'
import { escape, unescape } from './utils/escape'
import { segment } from './utils/segment'
import { compoundsForSelectors, IS_VALID_VARIANT_NAME } from './variants'
export type Config = UserConfig
@ -467,7 +468,7 @@ async function parseCss(
if (child.kind === 'comment') return
if (child.kind === 'declaration' && child.property.startsWith('--')) {
theme.add(child.property, child.value ?? '', themeOptions)
theme.add(unescape(child.property), child.value ?? '', themeOptions)
return
}
@ -526,7 +527,7 @@ async function parseCss(
for (let [key, value] of theme.entries()) {
if (value.options & ThemeOptions.REFERENCE) continue
nodes.push(decl(key, value.value))
nodes.push(decl(escape(key), value.value))
}
let keyframesRules = theme.getKeyframes()

View file

@ -41,10 +41,6 @@ export class Theme {
) {}
add(key: string, value: string, options = ThemeOptions.NONE): void {
if (key.endsWith('\\*')) {
key = key.slice(0, -2) + '*'
}
if (key.endsWith('-*')) {
if (value !== 'initial') {
throw new Error(`Invalid theme value \`${value}\` for namespace \`${key}\``)
@ -149,11 +145,20 @@ export class Theme {
#resolveKey(candidateValue: string | null, themeKeys: ThemeKey[]): string | null {
for (let namespace of themeKeys) {
let themeKey =
candidateValue !== null
? (escape(`${namespace}-${candidateValue.replaceAll('.', '_')}`) as ThemeKey)
: namespace
candidateValue !== null ? (`${namespace}-${candidateValue}` as ThemeKey) : namespace
if (!this.values.has(themeKey)) {
// If the exact theme key is not found, we might be trying to resolve a key containing a dot
// that was registered with an underscore instead:
if (candidateValue !== null && candidateValue.includes('.')) {
themeKey = `${namespace}-${candidateValue.replaceAll('.', '_')}` as ThemeKey
if (!this.values.has(themeKey)) continue
} else {
continue
}
}
if (!this.values.has(themeKey)) continue
if (isIgnoredThemeKey(themeKey, namespace)) continue
return themeKey
@ -167,7 +172,7 @@ export class Theme {
return null
}
return `var(${this.#prefixKey(themeKey)})`
return `var(${escape(this.#prefixKey(themeKey))})`
}
resolve(candidateValue: string | null, themeKeys: ThemeKey[]): string | null {