From 7f1d0970c3bd91da6c860a77b0b63f12d18d5a9d Mon Sep 17 00:00:00 2001 From: Robin Malfait Date: Fri, 31 Jan 2025 17:56:52 +0100 Subject: [PATCH] Do not emit empty rules/at-rules (#16121) This PR is an optimization where it will not emit empty rules and at-rules. I noticed this while working on https://github.com/tailwindlabs/tailwindcss/pull/16120 where we emitted: ```css :root, :host { } ``` There are some exceptions for "empty" at-rules, such as: ```css @charset "UTF-8"; @layer foo, bar, baz; @custom-media --modern (color), (hover); @namespace "http://www.w3.org/1999/xhtml"; ``` These don't have a body, but they still have a purpose and therefore they will be emitted. However, if you look at this: ```css /* Empty rule */ .foo { } /* Empty rule, with nesting */ .foo { .bar { } .baz { } } /* Empty rule, with special case '&' rules */ .foo { & { &:hover { } &:focus { } } } /* Empty at-rule */ @media (min-width: 768px) { } /* Empty at-rule with nesting*/ @media (min-width: 768px) { .foo { } @media (min-width: 1024px) { .bar { } } } ``` None of these will be emitted. --------- Co-authored-by: Jordan Pittman --- CHANGELOG.md | 1 + integrations/cli/index.test.ts | 7 +- packages/tailwindcss/src/ast.test.ts | 90 +++++++++++++++++++ packages/tailwindcss/src/ast.ts | 18 +++- .../tailwindcss/src/compat/plugin-api.test.ts | 2 - .../src/compat/screens-config.test.ts | 6 +- 6 files changed, 108 insertions(+), 16 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 114412e42d3c..45e54de0830b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -20,6 +20,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Prevent camelCasing CSS custom properties added by JavaScript plugins ([#16103](https://github.com/tailwindlabs/tailwindcss/pull/16103)) - Do not emit `@keyframes` in `@theme reference` ([#16120](https://github.com/tailwindlabs/tailwindcss/pull/16120)) - Discard invalid declarations when parsing CSS ([#16093](https://github.com/tailwindlabs/tailwindcss/pull/16093)) +- Do not emit empty CSS rules and at-rules ([#16121](https://github.com/tailwindlabs/tailwindcss/pull/16121)) ## [4.0.1] - 2025-01-29 diff --git a/integrations/cli/index.test.ts b/integrations/cli/index.test.ts index 2e6b9ad03549..0e3aba4c4e88 100644 --- a/integrations/cli/index.test.ts +++ b/integrations/cli/index.test.ts @@ -368,12 +368,7 @@ describe.each([ `, ) - await fs.expectFileToContain('project-a/dist/out.css', [ - css` - :root, :host { - } - `, - ]) + await fs.expectFileToContain('project-a/dist/out.css', [css``]) }, ) diff --git a/packages/tailwindcss/src/ast.test.ts b/packages/tailwindcss/src/ast.test.ts index c3d9b2f159bf..174971ea541d 100644 --- a/packages/tailwindcss/src/ast.test.ts +++ b/packages/tailwindcss/src/ast.test.ts @@ -2,6 +2,8 @@ import { expect, it } from 'vitest' import { context, decl, optimizeAst, styleRule, toCss, walk, WalkAction } from './ast' import * as CSS from './css-parser' +const css = String.raw + it('should pretty print an AST', () => { expect(toCss(optimizeAst(CSS.parse('.foo{color:red;&:hover{color:blue;}}')))) .toMatchInlineSnapshot(` @@ -95,3 +97,91 @@ it('should stop walking when returning `WalkAction.Stop`', () => { } `) }) + +it('should not emit empty rules once optimized', () => { + let ast = CSS.parse(css` + /* Empty rule */ + .foo { + } + + /* Empty rule, with nesting */ + .foo { + .bar { + } + .baz { + } + } + + /* Empty rule, with special case '&' rules */ + .foo { + & { + &:hover { + } + &:focus { + } + } + } + + /* Empty at-rule */ + @media (min-width: 768px) { + } + + /* Empty at-rule with nesting*/ + @media (min-width: 768px) { + .foo { + } + + @media (min-width: 1024px) { + .bar { + } + } + } + + /* Exceptions: */ + @charset "UTF-8"; + @layer foo, bar, baz; + @custom-media --modern (color), (hover); + @namespace 'http://www.w3.org/1999/xhtml'; + `) + + expect(toCss(ast)).toMatchInlineSnapshot(` + ".foo { + } + .foo { + .bar { + } + .baz { + } + } + .foo { + & { + &:hover { + } + &:focus { + } + } + } + @media (min-width: 768px); + @media (min-width: 768px) { + .foo { + } + @media (min-width: 1024px) { + .bar { + } + } + } + @charset "UTF-8"; + @layer foo, bar, baz; + @custom-media --modern (color), (hover); + @namespace 'http://www.w3.org/1999/xhtml'; + " + `) + + expect(toCss(optimizeAst(ast))).toMatchInlineSnapshot(` + "@charset "UTF-8"; + @layer foo, bar, baz; + @custom-media --modern (color), (hover); + @namespace 'http://www.w3.org/1999/xhtml'; + " + `) +}) diff --git a/packages/tailwindcss/src/ast.ts b/packages/tailwindcss/src/ast.ts index 6052724f33b9..7f67fdda5f57 100644 --- a/packages/tailwindcss/src/ast.ts +++ b/packages/tailwindcss/src/ast.ts @@ -261,7 +261,9 @@ export function optimizeAst(ast: AstNode[]) { for (let child of node.nodes) { let nodes: AstNode[] = [] transform(child, nodes, depth + 1) - parent.push(...nodes) + if (nodes.length > 0) { + parent.push(...nodes) + } } } @@ -271,7 +273,9 @@ export function optimizeAst(ast: AstNode[]) { for (let child of node.nodes) { transform(child, copy.nodes, depth + 1) } - parent.push(copy) + if (copy.nodes.length > 0) { + parent.push(copy) + } } } @@ -297,7 +301,15 @@ export function optimizeAst(ast: AstNode[]) { for (let child of node.nodes) { transform(child, copy.nodes, depth + 1) } - parent.push(copy) + if ( + copy.nodes.length > 0 || + copy.name === '@layer' || + copy.name === '@charset' || + copy.name === '@custom-media' || + copy.name === '@namespace' + ) { + parent.push(copy) + } } // AtRoot diff --git a/packages/tailwindcss/src/compat/plugin-api.test.ts b/packages/tailwindcss/src/compat/plugin-api.test.ts index 4502118f458f..9eb65ccb954a 100644 --- a/packages/tailwindcss/src/compat/plugin-api.test.ts +++ b/packages/tailwindcss/src/compat/plugin-api.test.ts @@ -3167,8 +3167,6 @@ describe('addUtilities()', () => { color: red; } } - } - :root, :host { }" `, ) diff --git a/packages/tailwindcss/src/compat/screens-config.test.ts b/packages/tailwindcss/src/compat/screens-config.test.ts index 09ab97fc9f8a..e8f96ee55fd3 100644 --- a/packages/tailwindcss/src/compat/screens-config.test.ts +++ b/packages/tailwindcss/src/compat/screens-config.test.ts @@ -655,9 +655,5 @@ test('JS config `screens` can overwrite default CSS `--breakpoint-*`', async () // currently. expect( compiler.build(['min-sm:flex', 'min-md:flex', 'min-lg:flex', 'min-xl:flex', 'min-2xl:flex']), - ).toMatchInlineSnapshot(` - ":root, :host { - } - " - `) + ).toMatchInlineSnapshot(`""`) })