Skip to content

Commit 811e97d

Browse files
Fix polyfill in combination with unused CSS variable removal (#17555)
This PR fixes an issue we noticed while investigating #17553, where the unused CSS variable removal didn't work properly when the theme variable it tried to remove was modified by a polyfill rule. The way the bookkeeping for the unused CSS variable worked was that it tired to find the declaration inside it's parent after the traversal. However, the `color-mix(…)` polyfill has since then made changes to the declaration so it can't find it's position correctly anymore and will thus instead delete the last declaration of the node (this caused unrelated CSS variables to be eliminated while the ones with `color-mix(…)` were unexpectedly kept). To fix this, we decided to apply the polyfills after any eventual deletions. This also ensures that no `@supports` query for the variables are created and simplifies the code a bit since all polyfills are now colocated. ## Test plan - Added a unit test for the example we discovered in #17553 - Luckily the conditions of this seemed rare enough so that it doesn't cause any other of our tests to update. --------- Co-authored-by: Robin Malfait <malfait.robin@gmail.com>
1 parent 5a77c9d commit 811e97d

File tree

7 files changed

+492
-344
lines changed

7 files changed

+492
-344
lines changed

CHANGELOG.md

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
77

88
## [Unreleased]
99

10-
- Nothing yet!
10+
### Fixed
11+
12+
- Ensure `color-mix(…)` polyfills do not cause used CSS variables to be removed ([#17555](https://github.com/tailwindlabs/tailwindcss/pull/17555))
1113

1214
## [4.1.3] - 2025-04-04
1315

packages/tailwindcss/src/ast.ts

Lines changed: 72 additions & 70 deletions
Original file line numberDiff line numberDiff line change
@@ -266,10 +266,8 @@ export function optimizeAst(
266266
) {
267267
let atRoots: AstNode[] = []
268268
let seenAtProperties = new Set<string>()
269-
let cssThemeVariables = new DefaultMap<
270-
Extract<AstNode, { nodes: AstNode[] }>['nodes'],
271-
Set<Declaration>
272-
>(() => new Set())
269+
let cssThemeVariables = new DefaultMap<AstNode[], Set<Declaration>>(() => new Set())
270+
let colorMixDeclarations = new DefaultMap<AstNode[], Set<Declaration>>(() => new Set())
273271
let keyframes = new Set<AtRule>()
274272
let usedKeyframeNames = new Set()
275273

@@ -280,7 +278,7 @@ export function optimizeAst(
280278

281279
function transform(
282280
node: AstNode,
283-
parent: Extract<AstNode, { nodes: AstNode[] }>['nodes'],
281+
parent: AstNode[],
284282
context: Record<string, string | boolean> = {},
285283
depth = 0,
286284
) {
@@ -326,71 +324,7 @@ export function optimizeAst(
326324
// Create fallback values for usages of the `color-mix(…)` function that reference variables
327325
// found in the theme config.
328326
if (polyfills & Polyfills.ColorMix && node.value.includes('color-mix(')) {
329-
let ast = ValueParser.parse(node.value)
330-
331-
let requiresPolyfill = false
332-
ValueParser.walk(ast, (node, { replaceWith }) => {
333-
if (node.kind !== 'function' || node.value !== 'color-mix') return
334-
335-
let containsUnresolvableVars = false
336-
let containsCurrentcolor = false
337-
ValueParser.walk(node.nodes, (node, { replaceWith }) => {
338-
if (node.kind == 'word' && node.value.toLowerCase() === 'currentcolor') {
339-
containsCurrentcolor = true
340-
requiresPolyfill = true
341-
return
342-
}
343-
if (node.kind !== 'function' || node.value !== 'var') return
344-
let firstChild = node.nodes[0]
345-
if (!firstChild || firstChild.kind !== 'word') return
346-
347-
requiresPolyfill = true
348-
349-
let inlinedColor = designSystem.theme.resolveValue(null, [firstChild.value as any])
350-
if (!inlinedColor) {
351-
containsUnresolvableVars = true
352-
return
353-
}
354-
355-
replaceWith({ kind: 'word', value: inlinedColor })
356-
})
357-
358-
if (containsUnresolvableVars || containsCurrentcolor) {
359-
let separatorIndex = node.nodes.findIndex(
360-
(node) => node.kind === 'separator' && node.value.trim().includes(','),
361-
)
362-
if (separatorIndex === -1) return
363-
let firstColorValue =
364-
node.nodes.length > separatorIndex ? node.nodes[separatorIndex + 1] : null
365-
if (!firstColorValue) return
366-
replaceWith(firstColorValue)
367-
} else if (requiresPolyfill) {
368-
// Change the colorspace to `srgb` since the fallback values should not be represented as
369-
// `oklab(…)` functions again as their support in Safari <16 is very limited.
370-
let colorspace = node.nodes[2]
371-
if (
372-
colorspace.kind === 'word' &&
373-
(colorspace.value === 'oklab' ||
374-
colorspace.value === 'oklch' ||
375-
colorspace.value === 'lab' ||
376-
colorspace.value === 'lch')
377-
) {
378-
colorspace.value = 'srgb'
379-
}
380-
}
381-
})
382-
383-
if (requiresPolyfill) {
384-
let fallback = {
385-
...node,
386-
value: ValueParser.toCss(ast),
387-
}
388-
let colorMixQuery = rule('@supports (color: color-mix(in lab, red, red))', [node])
389-
390-
parent.push(fallback, colorMixQuery)
391-
392-
return
393-
}
327+
colorMixDeclarations.get(parent).add(node)
394328
}
395329

396330
parent.push(node)
@@ -595,6 +529,74 @@ export function optimizeAst(
595529
newAst = newAst.concat(atRoots)
596530

597531
// Fallbacks
532+
// Create fallback values for usages of the `color-mix(…)` function that reference variables
533+
// found in the theme config.
534+
if (polyfills & Polyfills.ColorMix) {
535+
for (let [parent, declarations] of colorMixDeclarations) {
536+
for (let declaration of declarations) {
537+
let idx = parent.indexOf(declaration)
538+
// If the declaration is no longer present, we don't need to create a polyfill anymore
539+
if (idx === -1 || declaration.value == null) continue
540+
541+
let ast = ValueParser.parse(declaration.value)
542+
let requiresPolyfill = false
543+
ValueParser.walk(ast, (node, { replaceWith }) => {
544+
if (node.kind !== 'function' || node.value !== 'color-mix') return
545+
let containsUnresolvableVars = false
546+
let containsCurrentcolor = false
547+
ValueParser.walk(node.nodes, (node, { replaceWith }) => {
548+
if (node.kind == 'word' && node.value.toLowerCase() === 'currentcolor') {
549+
containsCurrentcolor = true
550+
requiresPolyfill = true
551+
return
552+
}
553+
if (node.kind !== 'function' || node.value !== 'var') return
554+
let firstChild = node.nodes[0]
555+
if (!firstChild || firstChild.kind !== 'word') return
556+
requiresPolyfill = true
557+
let inlinedColor = designSystem.theme.resolveValue(null, [firstChild.value as any])
558+
if (!inlinedColor) {
559+
containsUnresolvableVars = true
560+
return
561+
}
562+
replaceWith({ kind: 'word', value: inlinedColor })
563+
})
564+
if (containsUnresolvableVars || containsCurrentcolor) {
565+
let separatorIndex = node.nodes.findIndex(
566+
(node) => node.kind === 'separator' && node.value.trim().includes(','),
567+
)
568+
if (separatorIndex === -1) return
569+
let firstColorValue =
570+
node.nodes.length > separatorIndex ? node.nodes[separatorIndex + 1] : null
571+
if (!firstColorValue) return
572+
replaceWith(firstColorValue)
573+
} else if (requiresPolyfill) {
574+
// Change the colorspace to `srgb` since the fallback values should not be represented as
575+
// `oklab(…)` functions again as their support in Safari <16 is very limited.
576+
let colorspace = node.nodes[2]
577+
if (
578+
colorspace.kind === 'word' &&
579+
(colorspace.value === 'oklab' ||
580+
colorspace.value === 'oklch' ||
581+
colorspace.value === 'lab' ||
582+
colorspace.value === 'lch')
583+
) {
584+
colorspace.value = 'srgb'
585+
}
586+
}
587+
})
588+
if (!requiresPolyfill) continue
589+
590+
let fallback = {
591+
...declaration,
592+
value: ValueParser.toCss(ast),
593+
}
594+
let colorMixQuery = rule('@supports (color: color-mix(in lab, red, red))', [declaration])
595+
parent.splice(idx, 1, fallback, colorMixQuery)
596+
}
597+
}
598+
}
599+
598600
if (polyfills & Polyfills.AtProperty) {
599601
let fallbackAst = []
600602

packages/tailwindcss/src/compat/config.test.ts

Lines changed: 13 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -1164,21 +1164,21 @@ test('utilities must be prefixed', async () => {
11641164
// Prefixed utilities are generated
11651165
expect(compiler.build(['tw:underline', 'tw:hover:line-through', 'tw:custom']))
11661166
.toMatchInlineSnapshot(`
1167-
".tw\\:custom {
1168-
color: red;
1169-
}
1170-
.tw\\:underline {
1171-
text-decoration-line: underline;
1172-
}
1173-
.tw\\:hover\\:line-through {
1174-
&:hover {
1175-
@media (hover: hover) {
1176-
text-decoration-line: line-through;
1167+
".tw\\:custom {
1168+
color: red;
1169+
}
1170+
.tw\\:underline {
1171+
text-decoration-line: underline;
1172+
}
1173+
.tw\\:hover\\:line-through {
1174+
&:hover {
1175+
@media (hover: hover) {
1176+
text-decoration-line: line-through;
1177+
}
11771178
}
11781179
}
1179-
}
1180-
"
1181-
`)
1180+
"
1181+
`)
11821182

11831183
// Non-prefixed utilities are ignored
11841184
compiler = await compile(input, {

packages/tailwindcss/src/compat/plugin-api.test.ts

Lines changed: 45 additions & 45 deletions
Original file line numberDiff line numberDiff line change
@@ -243,14 +243,14 @@ describe('theme', async () => {
243243

244244
expect(compiler.build(['animate-duration-316', 'animate-duration-slow']))
245245
.toMatchInlineSnapshot(`
246-
".animate-duration-316 {
247-
animation-duration: 316ms;
248-
}
249-
.animate-duration-slow {
250-
animation-duration: 800ms;
251-
}
252-
"
253-
`)
246+
".animate-duration-316 {
247+
animation-duration: 316ms;
248+
}
249+
.animate-duration-slow {
250+
animation-duration: 800ms;
251+
}
252+
"
253+
`)
254254
})
255255

256256
test('plugin theme can have opacity modifiers', async () => {
@@ -3340,16 +3340,16 @@ describe('matchUtilities()', () => {
33403340
}
33413341

33423342
expect(optimizeCss(await run(['@w-1', 'hover:@w-1'])).trim()).toMatchInlineSnapshot(`
3343-
".\\@w-1 {
3343+
".\\@w-1 {
3344+
width: 1px;
3345+
}
3346+
3347+
@media (hover: hover) {
3348+
.hover\\:\\@w-1:hover {
33443349
width: 1px;
33453350
}
3346-
3347-
@media (hover: hover) {
3348-
.hover\\:\\@w-1:hover {
3349-
width: 1px;
3350-
}
3351-
}"
3352-
`)
3351+
}"
3352+
`)
33533353
})
33543354

33553355
test('custom functional utilities can return an array of rules', async () => {
@@ -4153,30 +4153,30 @@ describe('addComponents()', () => {
41534153

41544154
expect(optimizeCss(compiled.build(['btn', 'btn-blue', 'btn-red'])).trim())
41554155
.toMatchInlineSnapshot(`
4156-
".btn {
4157-
border-radius: .25rem;
4158-
padding: .5rem 1rem;
4159-
font-weight: 600;
4160-
}
4156+
".btn {
4157+
border-radius: .25rem;
4158+
padding: .5rem 1rem;
4159+
font-weight: 600;
4160+
}
41614161
4162-
.btn-blue {
4163-
color: #fff;
4164-
background-color: #3490dc;
4165-
}
4162+
.btn-blue {
4163+
color: #fff;
4164+
background-color: #3490dc;
4165+
}
41664166
4167-
.btn-blue:hover {
4168-
background-color: #2779bd;
4169-
}
4167+
.btn-blue:hover {
4168+
background-color: #2779bd;
4169+
}
41704170
4171-
.btn-red {
4172-
color: #fff;
4173-
background-color: #e3342f;
4174-
}
4171+
.btn-red {
4172+
color: #fff;
4173+
background-color: #e3342f;
4174+
}
41754175
4176-
.btn-red:hover {
4177-
background-color: #cc1f1a;
4178-
}"
4179-
`)
4176+
.btn-red:hover {
4177+
background-color: #cc1f1a;
4178+
}"
4179+
`)
41804180
})
41814181
})
41824182

@@ -4212,16 +4212,16 @@ describe('matchComponents()', () => {
42124212

42134213
expect(optimizeCss(compiled.build(['prose', 'sm:prose-sm', 'hover:prose-lg'])).trim())
42144214
.toMatchInlineSnapshot(`
4215-
".prose {
4216-
--container-size: normal;
4217-
}
4218-
4219-
@media (hover: hover) {
4220-
.hover\\:prose-lg:hover {
4221-
--container-size: lg;
4215+
".prose {
4216+
--container-size: normal;
42224217
}
4223-
}"
4224-
`)
4218+
4219+
@media (hover: hover) {
4220+
.hover\\:prose-lg:hover {
4221+
--container-size: lg;
4222+
}
4223+
}"
4224+
`)
42254225
})
42264226
})
42274227

packages/tailwindcss/src/css-functions.test.ts

Lines changed: 8 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -756,10 +756,10 @@ describe('theme(…)', () => {
756756
}
757757
`),
758758
).toMatchInlineSnapshot(`
759-
".fam {
760-
font-family: ui-sans-serif, system-ui, sans-serif, Apple Color Emoji, Segoe UI Emoji, Segoe UI Symbol, Noto Color Emoji;
761-
}"
762-
`)
759+
".fam {
760+
font-family: ui-sans-serif, system-ui, sans-serif, Apple Color Emoji, Segoe UI Emoji, Segoe UI Symbol, Noto Color Emoji;
761+
}"
762+
`)
763763
})
764764

765765
test('theme(fontFamily.sans) (config)', async () => {
@@ -776,10 +776,10 @@ describe('theme(…)', () => {
776776
)
777777

778778
expect(optimizeCss(compiled.build([])).trim()).toMatchInlineSnapshot(`
779-
".fam {
780-
font-family: ui-sans-serif, system-ui, sans-serif, Apple Color Emoji, Segoe UI Emoji, Segoe UI Symbol, Noto Color Emoji;
781-
}"
782-
`)
779+
".fam {
780+
font-family: ui-sans-serif, system-ui, sans-serif, Apple Color Emoji, Segoe UI Emoji, Segoe UI Symbol, Noto Color Emoji;
781+
}"
782+
`)
783783
})
784784
})
785785

0 commit comments

Comments
 (0)