diff --git a/CHANGELOG.md b/CHANGELOG.md
index 24d35cb1e67f..92bf54c672c6 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -18,6 +18,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- Ensure third-party plugins with `exports` in their `package.json` are resolved correctly ([#14775](https://github.com/tailwindlabs/tailwindcss/pull/14775))
- Ensure underscores in the `url()` function are never unescaped ([#14776](https://github.com/tailwindlabs/tailwindcss/pull/14776))
- _Upgrade (experimental)_: Ensure `@import` statements for relative CSS files are actually migrated to use relative path syntax ([#14769](https://github.com/tailwindlabs/tailwindcss/pull/14769))
+- _Upgrade (experimental)_: Ensure that CSS variable access when migrating arbitrary candidates that reference theme values with dots in the key path do not require escaping (e.g. `m-[var(--spacing-1_5)]`) ([#14778](https://github.com/tailwindlabs/tailwindcss/pull/14778))
## [4.0.0-alpha.29] - 2024-10-23
diff --git a/integrations/upgrade/index.test.ts b/integrations/upgrade/index.test.ts
index 83d1bc89e860..56767178606a 100644
--- a/integrations/upgrade/index.test.ts
+++ b/integrations/upgrade/index.test.ts
@@ -1,5 +1,5 @@
import { expect } from 'vitest'
-import { css, html, js, json, test } from '../utils'
+import { candidate, css, html, js, json, test } from '../utils'
test(
`upgrades a v3 project to v4`,
@@ -9,6 +9,9 @@ test(
{
"dependencies": {
"@tailwindcss/upgrade": "workspace:^"
+ },
+ "devDependencies": {
+ "@tailwindcss/cli": "workspace:^"
}
}
`,
@@ -20,7 +23,9 @@ test(
`,
'src/index.html': html`
🤠👋
-
+
`,
'src/input.css': css`
@tailwind base;
@@ -42,7 +47,9 @@ test(
"
--- ./src/index.html ---
🤠👋
-
+
--- ./src/input.css ---
@import 'tailwindcss';
@@ -92,6 +99,18 @@ test(
expect(packageJson.dependencies).toMatchObject({
tailwindcss: expect.stringContaining('4.0.0'),
})
+
+ // Ensure the v4 project compiles correctly
+ await exec('npx tailwindcss --input src/input.css --output dist/out.css')
+
+ await fs.expectFileToContain('dist/out.css', [
+ candidate`flex!`,
+ candidate`sm:block!`,
+ candidate`bg-linear-to-t`,
+ candidate`bg-[var(--my-red)]`,
+ candidate`max-w-[var(--breakpoint-md)]`,
+ candidate`ml-[var(--spacing-1\_5)`,
+ ])
},
)
diff --git a/packages/@tailwindcss-upgrade/src/template/candidates.test.ts b/packages/@tailwindcss-upgrade/src/template/candidates.test.ts
index 296a5cc5e8d5..4c310fe6f815 100644
--- a/packages/@tailwindcss-upgrade/src/template/candidates.test.ts
+++ b/packages/@tailwindcss-upgrade/src/template/candidates.test.ts
@@ -118,6 +118,13 @@ const candidates = [
// Keep spaces in strings
['content-["hello_world"]', 'content-["hello_world"]'],
['content-[____"hello_world"___]', 'content-["hello_world"]'],
+
+ // Do not escape underscores for url() and CSS variable in var()
+ ['bg-[no-repeat_url(/image_13.png)]', 'bg-[no-repeat_url(/image_13.png)]'],
+ [
+ 'bg-[var(--spacing-0_5,_var(--spacing-1_5,_3rem))]',
+ 'bg-[var(--spacing-0_5,_var(--spacing-1_5,_3rem))]',
+ ],
]
const variants = [
diff --git a/packages/@tailwindcss-upgrade/src/template/candidates.ts b/packages/@tailwindcss-upgrade/src/template/candidates.ts
index b97ff8ed04ab..e71fa462ed8c 100644
--- a/packages/@tailwindcss-upgrade/src/template/candidates.ts
+++ b/packages/@tailwindcss-upgrade/src/template/candidates.ts
@@ -187,9 +187,9 @@ function printArbitraryValue(input: string) {
})
}
+ recursivelyEscapeUnderscores(ast)
+
return ValueParser.toCss(ast)
- .replaceAll('_', String.raw`\_`) // Escape underscores to keep them as-is
- .replaceAll(' ', '_') // Replace spaces with underscores
}
function simplifyArbitraryVariant(input: string) {
@@ -213,3 +213,51 @@ function simplifyArbitraryVariant(input: string) {
return input
}
+
+function recursivelyEscapeUnderscores(ast: ValueParser.ValueAstNode[]) {
+ for (let node of ast) {
+ switch (node.kind) {
+ case 'function': {
+ if (node.value === 'url' || node.value.endsWith('_url')) {
+ // Don't decode underscores in url() but do decode the function name
+ node.value = escapeUnderscore(node.value)
+ break
+ }
+
+ if (node.value === 'var' || node.value.endsWith('_var')) {
+ // Don't decode underscores in the first argument of var() but do
+ // decode the function name
+ node.value = escapeUnderscore(node.value)
+ for (let i = 0; i < node.nodes.length; i++) {
+ if (i == 0 && node.nodes[i].kind === 'word') {
+ continue
+ }
+ recursivelyEscapeUnderscores([node.nodes[i]])
+ }
+ break
+ }
+
+ node.value = escapeUnderscore(node.value)
+ recursivelyEscapeUnderscores(node.nodes)
+ break
+ }
+ case 'separator':
+ case 'word': {
+ node.value = escapeUnderscore(node.value)
+ break
+ }
+ default:
+ never()
+ }
+ }
+}
+
+function never(): never {
+ throw new Error('This should never happen')
+}
+
+function escapeUnderscore(value: string): string {
+ return value
+ .replaceAll('_', String.raw`\_`) // Escape underscores to keep them as-is
+ .replaceAll(' ', '_') // Replace spaces with underscores
+}