diff --git a/CHANGELOG.md b/CHANGELOG.md index d19e60de1a94..ff8484795fd8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Changed - Drop warning from browser build ([#18731](https://github.com/tailwindlabs/tailwindcss/issues/18731)) +- Drop exact duplicate declarations when emitting CSS ([#18809](https://github.com/tailwindlabs/tailwindcss/issues/18809)) ### Fixed diff --git a/packages/tailwindcss/src/ast.test.ts b/packages/tailwindcss/src/ast.test.ts index 9f879d64b10a..865121c694d4 100644 --- a/packages/tailwindcss/src/ast.test.ts +++ b/packages/tailwindcss/src/ast.test.ts @@ -202,6 +202,93 @@ it('should not emit empty rules once optimized', () => { `) }) +it('should not emit exact duplicate declarations in the same rule', () => { + let ast = CSS.parse(css` + .foo { + color: red; + .bar { + color: green; + color: blue; + color: green; + } + color: red; + } + .foo { + color: red; + & { + color: green; + & { + color: red; + color: green; + color: blue; + } + color: red; + } + background: blue; + .bar { + color: green; + color: blue; + color: green; + } + caret-color: orange; + } + `) + + expect(toCss(ast)).toMatchInlineSnapshot(` + ".foo { + color: red; + .bar { + color: green; + color: blue; + color: green; + } + color: red; + } + .foo { + color: red; + & { + color: green; + & { + color: red; + color: green; + color: blue; + } + color: red; + } + background: blue; + .bar { + color: green; + color: blue; + color: green; + } + caret-color: orange; + } + " + `) + + expect(toCss(optimizeAst(ast, defaultDesignSystem))).toMatchInlineSnapshot(` + ".foo { + .bar { + color: blue; + color: green; + } + color: red; + } + .foo { + color: green; + color: blue; + color: red; + background: blue; + .bar { + color: blue; + color: green; + } + caret-color: orange; + } + " + `) +}) + it('should only visit children once when calling `replaceWith` with single element array', () => { let visited = new Set() diff --git a/packages/tailwindcss/src/ast.ts b/packages/tailwindcss/src/ast.ts index e4b5d5a9ab52..a473b9193e7f 100644 --- a/packages/tailwindcss/src/ast.ts +++ b/packages/tailwindcss/src/ast.ts @@ -351,27 +351,45 @@ export function optimizeAst( // Rule else if (node.kind === 'rule') { - // Rules with `&` as the selector should be flattened - if (node.selector === '&') { - for (let child of node.nodes) { - let nodes: AstNode[] = [] - transform(child, nodes, context, depth + 1) - if (nodes.length > 0) { - parent.push(...nodes) - } - } + let nodes: AstNode[] = [] + + for (let child of node.nodes) { + transform(child, nodes, context, depth + 1) } - // - else { - let copy = { ...node, nodes: [] } - for (let child of node.nodes) { - transform(child, copy.nodes, context, depth + 1) - } - if (copy.nodes.length > 0) { - parent.push(copy) + // Keep the last decl when there are exact duplicates. Keeping the *first* one might + // not be correct when given nested rules where a rule sits between declarations. + let seen: Record = {} + let toRemove = new Set() + + // Keep track of all nodes that produce a given declaration + for (let child of nodes) { + if (child.kind !== 'declaration') continue + + let key = `${child.property}:${child.value}:${child.important}` + seen[key] ??= [] + seen[key].push(child) + } + + // And remove all but the last of each + for (let key in seen) { + for (let i = 0; i < seen[key].length - 1; ++i) { + toRemove.add(seen[key][i]) } } + + if (toRemove.size > 0) { + nodes = nodes.filter((node) => !toRemove.has(node)) + } + + if (nodes.length === 0) return + + // Rules with `&` as the selector should be flattened + if (node.selector === '&') { + parent.push(...nodes) + } else { + parent.push({ ...node, nodes }) + } } // AtRule `@property` diff --git a/packages/tailwindcss/src/index.test.ts b/packages/tailwindcss/src/index.test.ts index a19fcf96d979..cbdb88186dc9 100644 --- a/packages/tailwindcss/src/index.test.ts +++ b/packages/tailwindcss/src/index.test.ts @@ -527,7 +527,6 @@ describe('@apply', () => { .foo, .bar { --tw-content: "b"; content: var(--tw-content); - content: var(--tw-content); } @property --tw-content {