Skip to content

Commit cb36d6e

Browse files
committed
fix(beasties): trim empty @media and @supports blocks
1 parent 5fd0af3 commit cb36d6e

2 files changed

Lines changed: 248 additions & 2 deletions

File tree

packages/beasties/src/css.ts

Lines changed: 25 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -171,12 +171,24 @@ export function walkStyleRulesWithReverseMirror(node: Rule | Root_, node2: Rule
171171
const rule2 = rules2?.[index]
172172
if (hasNestedRules(rule)) {
173173
walkStyleRulesWithReverseMirror(rule, rule2 as Rule, iterator)
174+
if ('nodes' in rule && rule.nodes?.length === 0 && isRemovableIfEmpty(rule)) {
175+
return false
176+
}
174177
}
175178
rule._other = rule2 as Rule
176179
rule.filterSelectors = filterSelectors
177180
return iterator(rule) !== false
178181
},
179182
)
183+
184+
if (node2.nodes) {
185+
node2.nodes = node2.nodes.filter((rule) => {
186+
if ('nodes' in rule && rule.nodes?.length === 0 && isRemovableIfEmpty(rule)) {
187+
return false
188+
}
189+
return true
190+
})
191+
}
180192
}
181193

182194
// Checks if a node has nested rules, like @media
@@ -190,10 +202,20 @@ function hasNestedRules(rule: ChildNode): rule is Rule {
190202
)
191203
}
192204

205+
// Checks if an empty container rule should be removed
206+
// `@media` and `@supports` blocks are safe to remove when empty
207+
// `@layer` blocks should be preserved (even empty) as they establish cascade order
208+
function isRemovableIfEmpty(rule: ChildNode): boolean {
209+
if (!('name' in rule) || rule.type !== 'atrule') {
210+
return false
211+
}
212+
return rule.name === 'media' || rule.name === 'supports'
213+
}
214+
193215
// Like [].filter(), but applies the opposite filtering result to a second copy of the Array without a second pass.
194216
// This is just a quicker version of generating the compliment of the set returned from a filter operation.
195217
type SplitIterator<T> = (item: T, index: number, a: T[], b?: T[]) => boolean
196-
function splitFilter<T>(a: T[], b: T[], predicate: SplitIterator<T>) {
218+
function splitFilter<T>(a: T[], b: T[] | undefined, predicate: SplitIterator<T>) {
197219
const aOut: T[] = []
198220
const bOut: T[] = []
199221
for (let index = 0; index < a.length; index++) {
@@ -202,7 +224,8 @@ function splitFilter<T>(a: T[], b: T[], predicate: SplitIterator<T>) {
202224
aOut.push(item)
203225
}
204226
else {
205-
bOut.push(item)
227+
// Push from b if available (for mirrored trees), otherwise from a
228+
bOut.push(b?.[index] ?? item)
206229
}
207230
}
208231
return [aOut, bOut] as const

packages/beasties/test/beasties.test.ts

Lines changed: 223 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -785,4 +785,227 @@ describe('beasties', () => {
785785
// Clean up temporary directory
786786
fs.rmSync(tmpDir, { recursive: true })
787787
})
788+
789+
it('removes empty @media blocks when pruneSource is enabled', async () => {
790+
// Regression test for https://github.com/danielroe/beasties/issues/172
791+
// Empty @media blocks should be removed after pruning, not left behind
792+
const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'beasties-test-'))
793+
fs.writeFileSync(path.join(tmpDir, 'style.css'), trim`
794+
h1 { color: blue; }
795+
@media (min-width: 768px) {
796+
h1 { padding: 48px; }
797+
}
798+
h2.unused { color: red; }
799+
`)
800+
801+
const beasties = new Beasties({
802+
reduceInlineStyles: false,
803+
path: tmpDir,
804+
pruneSource: true,
805+
})
806+
807+
let writtenCss = ''
808+
beasties.writeFile = (filename, data) => new Promise((resolve, reject) => {
809+
try {
810+
writtenCss = data
811+
fs.writeFileSync(filename, data)
812+
resolve()
813+
}
814+
catch (err) {
815+
reject(err)
816+
}
817+
})
818+
819+
const result = await beasties.process(trim`
820+
<html>
821+
<head>
822+
<link rel="stylesheet" href="/style.css">
823+
</head>
824+
<body>
825+
<h1>Hello World!</h1>
826+
</body>
827+
</html>
828+
`)
829+
830+
// Critical CSS should include both the base h1 rule and the @media rule
831+
expect(result).toContain('h1{color:blue}')
832+
expect(result).toContain('@media (min-width: 768px)')
833+
expect(result).toContain('h1{padding:48px}')
834+
835+
// The pruned CSS file should NOT contain empty @media blocks
836+
expect(writtenCss).not.toContain('@media (min-width: 768px){}')
837+
expect(writtenCss).not.toContain('@media (min-width: 768px) {}')
838+
// It should only contain the unused h2 rule
839+
expect(writtenCss).toEqual('h2.unused{color:red}')
840+
841+
// Clean up temporary directory
842+
fs.rmSync(tmpDir, { recursive: true })
843+
})
844+
845+
it('removes empty @media blocks from critical CSS when rules go to pruned source', async () => {
846+
// Regression test for https://github.com/danielroe/beasties/issues/172
847+
// Test the inverse case: empty @media in critical CSS
848+
const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'beasties-test-'))
849+
fs.writeFileSync(path.join(tmpDir, 'style.css'), trim`
850+
h1 { color: blue; }
851+
@media (min-width: 768px) {
852+
h2.unused { padding: 48px; }
853+
}
854+
`)
855+
856+
const beasties = new Beasties({
857+
reduceInlineStyles: false,
858+
path: tmpDir,
859+
pruneSource: true,
860+
})
861+
862+
let writtenCss = ''
863+
beasties.writeFile = (filename, data) => new Promise((resolve, reject) => {
864+
try {
865+
writtenCss = data
866+
fs.writeFileSync(filename, data)
867+
resolve()
868+
}
869+
catch (err) {
870+
reject(err)
871+
}
872+
})
873+
874+
const result = await beasties.process(trim`
875+
<html>
876+
<head>
877+
<link rel="stylesheet" href="/style.css">
878+
</head>
879+
<body>
880+
<h1>Hello World!</h1>
881+
</body>
882+
</html>
883+
`)
884+
885+
// Critical CSS should NOT contain empty @media blocks
886+
expect(result).not.toContain('@media (min-width: 768px){}')
887+
expect(result).not.toContain('@media (min-width: 768px) {}')
888+
expect(result).toContain('h1{color:blue}')
889+
890+
// The pruned CSS should contain the @media block with the unused rule
891+
expect(writtenCss).toContain('@media (min-width: 768px)')
892+
expect(writtenCss).toContain('h2.unused{padding:48px}')
893+
894+
// Clean up temporary directory
895+
fs.rmSync(tmpDir, { recursive: true })
896+
})
897+
898+
it('removes empty @supports blocks when pruneSource is enabled', async () => {
899+
// Similar to @media, @supports blocks should be removed when empty
900+
const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'beasties-test-'))
901+
fs.writeFileSync(path.join(tmpDir, 'style.css'), trim`
902+
h1 { color: blue; }
903+
@supports (display: grid) {
904+
h1 { display: grid; }
905+
}
906+
h2.unused { color: red; }
907+
`)
908+
909+
const beasties = new Beasties({
910+
reduceInlineStyles: false,
911+
path: tmpDir,
912+
pruneSource: true,
913+
})
914+
915+
let writtenCss = ''
916+
beasties.writeFile = (filename, data) => new Promise((resolve, reject) => {
917+
try {
918+
writtenCss = data
919+
fs.writeFileSync(filename, data)
920+
resolve()
921+
}
922+
catch (err) {
923+
reject(err)
924+
}
925+
})
926+
927+
const result = await beasties.process(trim`
928+
<html>
929+
<head>
930+
<link rel="stylesheet" href="/style.css">
931+
</head>
932+
<body>
933+
<h1>Hello World!</h1>
934+
</body>
935+
</html>
936+
`)
937+
938+
// Critical CSS should include the @supports block with h1 rule
939+
expect(result).toContain('h1{color:blue}')
940+
expect(result).toContain('@supports (display: grid)')
941+
expect(result).toContain('h1{display:grid}')
942+
943+
// The pruned CSS file should NOT contain empty @supports blocks
944+
expect(writtenCss).not.toContain('@supports')
945+
// It should only contain the unused h2 rule
946+
expect(writtenCss).toEqual('h2.unused{color:red}')
947+
948+
// Clean up temporary directory
949+
fs.rmSync(tmpDir, { recursive: true })
950+
})
951+
952+
it('preserves @keyframes when pruneSource is enabled', async () => {
953+
// @keyframes should be handled as a whole, not recursively walked
954+
const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'beasties-test-'))
955+
fs.writeFileSync(path.join(tmpDir, 'style.css'), trim`
956+
h1 {
957+
color: blue;
958+
animation: fadeIn 1s;
959+
}
960+
@keyframes fadeIn {
961+
from { opacity: 0; }
962+
to { opacity: 1; }
963+
}
964+
@keyframes unused {
965+
0% { transform: scale(0); }
966+
100% { transform: scale(1); }
967+
}
968+
`)
969+
970+
const beasties = new Beasties({
971+
reduceInlineStyles: false,
972+
path: tmpDir,
973+
pruneSource: true,
974+
keyframes: 'critical',
975+
})
976+
977+
let writtenCss = ''
978+
beasties.writeFile = (filename, data) => new Promise((resolve, reject) => {
979+
try {
980+
writtenCss = data
981+
fs.writeFileSync(filename, data)
982+
resolve()
983+
}
984+
catch (err) {
985+
reject(err)
986+
}
987+
})
988+
989+
const result = await beasties.process(trim`
990+
<html>
991+
<head>
992+
<link rel="stylesheet" href="/style.css">
993+
</head>
994+
<body>
995+
<h1>Hello World!</h1>
996+
</body>
997+
</html>
998+
`)
999+
1000+
// Critical CSS should include the used @keyframes
1001+
expect(result).toContain('@keyframes fadeIn')
1002+
expect(result).toContain('from{opacity:0}')
1003+
expect(result).toContain('to{opacity:1}')
1004+
1005+
// Unused @keyframes should be in the pruned source
1006+
expect(writtenCss).toContain('@keyframes unused')
1007+
1008+
// Clean up temporary directory
1009+
fs.rmSync(tmpDir, { recursive: true })
1010+
})
7881011
})

0 commit comments

Comments
 (0)