diff --git a/core/computed/unused-css.js b/core/computed/unused-css.js index eab8948531bc..5aaa806ca7d7 100644 --- a/core/computed/unused-css.js +++ b/core/computed/unused-css.js @@ -70,9 +70,20 @@ class UnusedCSS { usedUncompressedBytes += usedRule.endOffset - usedRule.startOffset; } + // CSS coverage can report overlapping ranges (e.g. a nested rule that lives + // inside its parent rule's range) or the same rule more than once, so the + // summed used bytes can exceed the stylesheet size. Clamp to avoid computing + // a negative amount of unused (wasted) bytes. + // See https://github.com/GoogleChrome/lighthouse/issues/14718. + usedUncompressedBytes = Math.min(usedUncompressedBytes, totalUncompressedBytes); + const compressedSize = estimateCompressedContentSize( stylesheetInfo.networkRecord, totalUncompressedBytes, 'Stylesheet'); - const percentUnused = (totalUncompressedBytes - usedUncompressedBytes) / totalUncompressedBytes; + // Guard against empty stylesheets, whose 0-length content would otherwise + // make percentUnused NaN. + const percentUnused = totalUncompressedBytes > 0 ? + (totalUncompressedBytes - usedUncompressedBytes) / totalUncompressedBytes : + 0; const wastedBytes = Math.round(percentUnused * compressedSize); return { diff --git a/core/test/computed/unused-css-test.js b/core/test/computed/unused-css-test.js index fbee82c320ed..380a76aadf7d 100644 --- a/core/test/computed/unused-css-test.js +++ b/core/test/computed/unused-css-test.js @@ -108,6 +108,30 @@ describe('UnusedCSS computed artifact', () => { assert.equal(map({usedRules: [{startOffset: 0, endOffset: 5}]}).wastedPercent, 0); }); + it('does not report negative waste when used ranges overlap or repeat', () => { + // Overlapping ranges (e.g. a nested rule inside its parent's range) or a + // rule reported as used more than once can sum to more than the sheet size. + const overlapping = map({usedRules: [ + {startOffset: 0, endOffset: 5}, + {startOffset: 2, endOffset: 4}, + ]}); + assert.equal(overlapping.wastedBytes, 0); + assert.equal(overlapping.wastedPercent, 0); + + const duplicated = map({usedRules: [ + {startOffset: 0, endOffset: 5}, + {startOffset: 0, endOffset: 5}, + ]}); + assert.equal(duplicated.wastedBytes, 0); + assert.equal(duplicated.wastedPercent, 0); + }); + + it('does not report NaN for an empty stylesheet', () => { + const result = map({content: '', usedRules: []}); + assert.equal(result.wastedBytes, 0); + assert.equal(result.wastedPercent, 0); + }); + it('correctly computes url', () => { const expectedPreview = 'dummy'; assert.strictEqual(map({header: {sourceURL: '', isInline: false}}).url, expectedPreview);