Skip to content

Commit 5e2a160

Browse files
Drop exact duplicate declarations from output CSS within a style rule (#18809)
Fixes #18178 When someone writes a utility like `after:content-['foo']` it'll produce duplicate `content: var(--tw-content)` declarations. I thought about special casing these but we already have an optimization pass where we perform a full walk of the AST, flattening some rules (with the `&` selector), analyzing declarations, etc… We can utilize that existing spot in core to analyze and remove duplicate declarations within rules across the AST. The implementation does this by keeping track of declarations within a style rule and keeps the last one for any *exact duplicate* which is a tuple of `(property, value, important)`. This does require some additional loops but preseving the *last* declaration is important for correctness with regards to CSS nesting. For example take this nested CSS: ```css .foo { color: red; & .bar { color: green; } color: red; } ``` It expands to this: ```css .foo { color: red; } .foo.bar { color: green; } .foo { color: red; } ``` If you remove the *last* rule then a `<div class="foo bar">…</div>` will have green text when its supposed to be red. Since that would affect behavior we have to always preserve the last declaration for a given property. We could go further and eliminate multiple declarations for the same property *but* this presents a problem: every property and value must be understood and combined with browser targets to understand whether or not that property may act as a "fallback" or whether definitely overwrites its previous value in all cases. This is a much more complicated task that is much more suited to something light Lighting CSS.
1 parent b1fb02a commit 5e2a160

File tree

4 files changed

+123
-18
lines changed

4 files changed

+123
-18
lines changed

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
1010
### Changed
1111

1212
- Drop warning from browser build ([#18731](https://github.com/tailwindlabs/tailwindcss/issues/18731))
13+
- Drop exact duplicate declarations when emitting CSS ([#18809](https://github.com/tailwindlabs/tailwindcss/issues/18809))
1314

1415
### Fixed
1516

packages/tailwindcss/src/ast.test.ts

Lines changed: 87 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -202,6 +202,93 @@ it('should not emit empty rules once optimized', () => {
202202
`)
203203
})
204204

205+
it('should not emit exact duplicate declarations in the same rule', () => {
206+
let ast = CSS.parse(css`
207+
.foo {
208+
color: red;
209+
.bar {
210+
color: green;
211+
color: blue;
212+
color: green;
213+
}
214+
color: red;
215+
}
216+
.foo {
217+
color: red;
218+
& {
219+
color: green;
220+
& {
221+
color: red;
222+
color: green;
223+
color: blue;
224+
}
225+
color: red;
226+
}
227+
background: blue;
228+
.bar {
229+
color: green;
230+
color: blue;
231+
color: green;
232+
}
233+
caret-color: orange;
234+
}
235+
`)
236+
237+
expect(toCss(ast)).toMatchInlineSnapshot(`
238+
".foo {
239+
color: red;
240+
.bar {
241+
color: green;
242+
color: blue;
243+
color: green;
244+
}
245+
color: red;
246+
}
247+
.foo {
248+
color: red;
249+
& {
250+
color: green;
251+
& {
252+
color: red;
253+
color: green;
254+
color: blue;
255+
}
256+
color: red;
257+
}
258+
background: blue;
259+
.bar {
260+
color: green;
261+
color: blue;
262+
color: green;
263+
}
264+
caret-color: orange;
265+
}
266+
"
267+
`)
268+
269+
expect(toCss(optimizeAst(ast, defaultDesignSystem))).toMatchInlineSnapshot(`
270+
".foo {
271+
.bar {
272+
color: blue;
273+
color: green;
274+
}
275+
color: red;
276+
}
277+
.foo {
278+
color: green;
279+
color: blue;
280+
color: red;
281+
background: blue;
282+
.bar {
283+
color: blue;
284+
color: green;
285+
}
286+
caret-color: orange;
287+
}
288+
"
289+
`)
290+
})
291+
205292
it('should only visit children once when calling `replaceWith` with single element array', () => {
206293
let visited = new Set()
207294

packages/tailwindcss/src/ast.ts

Lines changed: 35 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -351,27 +351,45 @@ export function optimizeAst(
351351

352352
// Rule
353353
else if (node.kind === 'rule') {
354-
// Rules with `&` as the selector should be flattened
355-
if (node.selector === '&') {
356-
for (let child of node.nodes) {
357-
let nodes: AstNode[] = []
358-
transform(child, nodes, context, depth + 1)
359-
if (nodes.length > 0) {
360-
parent.push(...nodes)
361-
}
362-
}
354+
let nodes: AstNode[] = []
355+
356+
for (let child of node.nodes) {
357+
transform(child, nodes, context, depth + 1)
363358
}
364359

365-
//
366-
else {
367-
let copy = { ...node, nodes: [] }
368-
for (let child of node.nodes) {
369-
transform(child, copy.nodes, context, depth + 1)
370-
}
371-
if (copy.nodes.length > 0) {
372-
parent.push(copy)
360+
// Keep the last decl when there are exact duplicates. Keeping the *first* one might
361+
// not be correct when given nested rules where a rule sits between declarations.
362+
let seen: Record<string, AstNode[]> = {}
363+
let toRemove = new Set<AstNode>()
364+
365+
// Keep track of all nodes that produce a given declaration
366+
for (let child of nodes) {
367+
if (child.kind !== 'declaration') continue
368+
369+
let key = `${child.property}:${child.value}:${child.important}`
370+
seen[key] ??= []
371+
seen[key].push(child)
372+
}
373+
374+
// And remove all but the last of each
375+
for (let key in seen) {
376+
for (let i = 0; i < seen[key].length - 1; ++i) {
377+
toRemove.add(seen[key][i])
373378
}
374379
}
380+
381+
if (toRemove.size > 0) {
382+
nodes = nodes.filter((node) => !toRemove.has(node))
383+
}
384+
385+
if (nodes.length === 0) return
386+
387+
// Rules with `&` as the selector should be flattened
388+
if (node.selector === '&') {
389+
parent.push(...nodes)
390+
} else {
391+
parent.push({ ...node, nodes })
392+
}
375393
}
376394

377395
// AtRule `@property`

packages/tailwindcss/src/index.test.ts

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -527,7 +527,6 @@ describe('@apply', () => {
527527
.foo, .bar {
528528
--tw-content: "b";
529529
content: var(--tw-content);
530-
content: var(--tw-content);
531530
}
532531
533532
@property --tw-content {

0 commit comments

Comments
 (0)