diff --git a/lighthouse-cli/test/smokehouse/dbw-config.js b/lighthouse-cli/test/smokehouse/dbw-config.js index 2bd795cfdea8..4f98d07e94c1 100644 --- a/lighthouse-cli/test/smokehouse/dbw-config.js +++ b/lighthouse-cli/test/smokehouse/dbw-config.js @@ -16,8 +16,7 @@ module.exports = { ], onlyAudits: [ 'dom-size', - 'link-blocking-first-paint', - 'script-blocking-first-paint', + 'render-blocking-resources', 'errors-in-console', ], }, diff --git a/lighthouse-cli/test/smokehouse/dobetterweb/dbw-expectations.js b/lighthouse-cli/test/smokehouse/dobetterweb/dbw-expectations.js index bec4bbb19801..f51764d97b96 100644 --- a/lighthouse-cli/test/smokehouse/dobetterweb/dbw-expectations.js +++ b/lighthouse-cli/test/smokehouse/dobetterweb/dbw-expectations.js @@ -67,22 +67,6 @@ module.exports = [ 'geolocation-on-start': { score: 0, }, - 'link-blocking-first-paint': { - score: 0, - rawValue: '<3000', - extendedInfo: { - value: { - results: { - length: 5, - }, - }, - }, - details: { - items: { - length: 5, - }, - }, - }, 'no-document-write': { score: 0, extendedInfo: { @@ -126,18 +110,12 @@ module.exports = [ 'notification-on-start': { score: 0, }, - 'script-blocking-first-paint': { + 'render-blocking-resources': { score: '<1', - extendedInfo: { - value: { - results: { - length: 2, - }, - }, - }, + rawValue: '>100', details: { items: { - length: 2, + length: 7, }, }, }, diff --git a/lighthouse-cli/test/smokehouse/offline-config.js b/lighthouse-cli/test/smokehouse/offline-config.js index 982ea0973edb..01a263ae96e2 100644 --- a/lighthouse-cli/test/smokehouse/offline-config.js +++ b/lighthouse-cli/test/smokehouse/offline-config.js @@ -23,8 +23,7 @@ module.exports = { 'without-javascript', 'user-timings', 'critical-request-chains', - 'link-blocking-first-paint', - 'script-blocking-first-paint', + 'render-blocking-resources', 'webapp-install-banner', 'splash-screen', 'themed-omnibox', diff --git a/lighthouse-cli/test/smokehouse/offline-local/offline-expectations.js b/lighthouse-cli/test/smokehouse/offline-local/offline-expectations.js index 63f7c9a4f049..2866857a0d4e 100644 --- a/lighthouse-cli/test/smokehouse/offline-local/offline-expectations.js +++ b/lighthouse-cli/test/smokehouse/offline-local/offline-expectations.js @@ -30,7 +30,7 @@ module.exports = [ 'geolocation-on-start': { score: 1, }, - 'link-blocking-first-paint': { + 'render-blocking-resources': { score: 1, }, 'no-document-write': { @@ -42,9 +42,6 @@ module.exports = [ 'no-websql': { score: 1, }, - 'script-blocking-first-paint': { - score: 1, - }, 'uses-passive-event-listeners': { score: 1, }, diff --git a/lighthouse-core/audits/byte-efficiency/byte-efficiency-audit.js b/lighthouse-core/audits/byte-efficiency/byte-efficiency-audit.js index 51d4a78db274..9162c6c7d4a9 100644 --- a/lighthouse-core/audits/byte-efficiency/byte-efficiency-audit.js +++ b/lighthouse-core/audits/byte-efficiency/byte-efficiency-audit.js @@ -78,8 +78,7 @@ class UnusedBytes extends Audit { const settings = context && context.settings || {}; const simulatorOptions = { devtoolsLog, - throttlingMethod: settings.throttlingMethod, - throttling: settings.throttling, + settings, }; return artifacts diff --git a/lighthouse-core/audits/byte-efficiency/render-blocking-resources.js b/lighthouse-core/audits/byte-efficiency/render-blocking-resources.js new file mode 100644 index 000000000000..fae4a1cbc9ef --- /dev/null +++ b/lighthouse-core/audits/byte-efficiency/render-blocking-resources.js @@ -0,0 +1,213 @@ +/** + * @license Copyright 2018 Google Inc. All Rights Reserved. + * Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. You may obtain a copy of the License at http://www.apache.org/licenses/LICENSE-2.0 + * Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions and limitations under the License. + */ + +/** + * @fileoverview Audit a page to see if it does have resources that are blocking first paint + */ + +'use strict'; + +const Audit = require('../audit'); +const Node = require('../../lib/dependency-graph/node'); +const ByteEfficiencyAudit = require('./byte-efficiency-audit'); +const UnusedCSS = require('./unused-css-rules'); +const WebInspector = require('../../lib/web-inspector'); + +// Because of the way we detect blocking stylesheets, asynchronously loaded +// CSS with link[rel=preload] and an onload handler (see https://github.com/filamentgroup/loadCSS) +// can be falsely flagged as blocking. Therefore, ignore stylesheets that loaded fast enough +// to possibly be non-blocking (and they have minimal impact anyway). +const MINIMUM_WASTED_MS = 50; + +/** + * Given a simulation's nodeTiming, return an object with the nodes/timing keyed by network URL + * @param {LH.Gatherer.Simulation.Result['nodeTiming']} nodeTimingMap + * @return {Object} + */ +const getNodesAndTimingByUrl = nodeTimingMap => { + const nodes = Array.from(nodeTimingMap.keys()); + return nodes.reduce((map, node) => { + map[node.record && node.record.url] = {node, nodeTiming: nodeTimingMap.get(node)}; + return map; + }, {}); +}; + +class RenderBlockingResources extends Audit { + /** + * @return {!AuditMeta} + */ + static get meta() { + return { + name: 'render-blocking-resources', + description: 'Eliminate render-blocking resources', + informative: true, + scoreDisplayMode: Audit.SCORING_MODES.NUMERIC, + helpText: + 'Resources are blocking the first paint of your page. Consider ' + + 'delivering critical JS/CSS inline and deferring all non-critical ' + + 'JS/styles. [Learn more](https://developers.google.com/web/tools/lighthouse/audits/blocking-resources).', + requiredArtifacts: ['CSSUsage', 'URL', 'TagsBlockingFirstPaint', 'traces'], + }; + } + + /** + * @param {Artifacts} artifacts + * @param {LH.Audit.Context} context + */ + static async computeResults(artifacts, context) { + const trace = artifacts.traces[Audit.DEFAULT_PASS]; + const devtoolsLog = artifacts.devtoolsLogs[Audit.DEFAULT_PASS]; + const simulatorData = {devtoolsLog, settings: context.settings}; + const traceOfTab = await artifacts.requestTraceOfTab(trace); + const simulator = await artifacts.requestLoadSimulator(simulatorData); + const wastedBytesMap = await RenderBlockingResources.computeWastedCSSBytes(artifacts, context); + + const metricSettings = {throttlingMethod: 'simulate'}; + const metricComputationData = {trace, devtoolsLog, simulator, settings: metricSettings}; + const fcpSimulation = await artifacts.requestFirstContentfulPaint(metricComputationData); + const fcpTsInMs = traceOfTab.timestamps.firstContentfulPaint / 1000; + + const nodesByUrl = getNodesAndTimingByUrl(fcpSimulation.optimisticEstimate.nodeTiming); + + const results = []; + const deferredNodeIds = new Set(); + for (const resource of artifacts.TagsBlockingFirstPaint) { + // Ignore any resources that finished after observed FCP (they're clearly not render-blocking) + if (resource.endTime * 1000 > fcpTsInMs) continue; + // TODO(phulce): beacon these occurences to Sentry to improve FCP graph + if (!nodesByUrl[resource.tag.url]) continue; + + const {node, nodeTiming} = nodesByUrl[resource.tag.url]; + + // Mark this node and all it's dependents as deferrable + // TODO(phulce): make this slightly more surgical + // i.e. the referenced font asset won't become inlined just because you inline the CSS + node.traverse(node => deferredNodeIds.add(node.id)); + + // "wastedMs" is the download time of the network request, responseReceived - requestSent + const wastedMs = Math.round(nodeTiming.endTime - nodeTiming.startTime); + if (wastedMs < MINIMUM_WASTED_MS) continue; + + results.push({ + url: resource.tag.url, + totalBytes: resource.transferSize, + wastedMs, + }); + } + + if (!results.length) { + return {results, wastedMs: 0}; + } + + const wastedMs = RenderBlockingResources.estimateSavingsWithGraphs( + simulator, + fcpSimulation.optimisticGraph, + deferredNodeIds, + wastedBytesMap + ); + + return {results, wastedMs}; + } + + /** + * Estimates how much faster this page would reach FCP if we inlined all the used CSS from the + * render blocking stylesheets and deferred all the scripts. This is more conservative than + * removing all the assets and more aggressive than inlining everything. + * + * *Most* of the time, scripts in the head are there accidentally/due to lack of awareness + * rather than necessity, so we're comfortable with this balance. In the worst case, we're telling + * devs that they should be able to get to a reasonable first paint without JS, which is not a bad + * thing. + * + * @param {Simulator} simulator + * @param {Node} fcpGraph + * @param {Set} deferredIds + * @param {Map} wastedBytesMap + * @return {number} + */ + static estimateSavingsWithGraphs(simulator, fcpGraph, deferredIds, wastedBytesMap) { + const originalEstimate = simulator.simulate(fcpGraph).timeInMs; + + let totalChildNetworkBytes = 0; + const minimalFCPGraph = fcpGraph.cloneWithRelationships(node => { + const canDeferRequest = deferredIds.has(node.id); + const isStylesheet = + node.type === Node.TYPES.NETWORK && + node.record._resourceType === WebInspector.resourceTypes.Stylesheet; + if (canDeferRequest && isStylesheet) { + // We'll inline the used bytes of the stylesheet and assume the rest can be deferred + const wastedBytes = wastedBytesMap.get(node.record.url) || 0; + totalChildNetworkBytes += node.record._transferSize - wastedBytes; + } + + // If a node can be deferred, exclude it from the new FCP graph + return !canDeferRequest; + }); + + // Add the inlined bytes to the HTML response + minimalFCPGraph.record._transferSize += totalChildNetworkBytes; + const estimateAfterInline = simulator.simulate(minimalFCPGraph).timeInMs; + minimalFCPGraph.record._transferSize -= totalChildNetworkBytes; + return Math.round(Math.max(originalEstimate - estimateAfterInline, 0)); + } + + /** + * @param {!Artifacts} artifacts + * @param {LH.Audit.Context} context + * @return {Map} + */ + static async computeWastedCSSBytes(artifacts, context) { + const wastedBytesByUrl = new Map(); + try { + const results = await UnusedCSS.audit(artifacts, context); + for (const item of results.details.items) { + wastedBytesByUrl.set(item.url, item.wastedBytes); + } + } catch (_) {} + + return wastedBytesByUrl; + } + + /** + * @param {!Artifacts} artifacts + * @param {LH.Audit.Context} context + * @return {AuditResult} + */ + static async audit(artifacts, context) { + const {results, wastedMs} = await RenderBlockingResources.computeResults(artifacts, context); + + let displayValue = ''; + if (results.length > 1) { + displayValue = `${results.length} resources delayed first paint by ${wastedMs}ms`; + } else if (results.length === 1) { + displayValue = `${results.length} resource delayed first paint by ${wastedMs}ms`; + } + + const headings = [ + {key: 'url', itemType: 'url', text: 'URL'}, + { + key: 'totalBytes', + itemType: 'bytes', + displayUnit: 'kb', + granularity: 0.01, + text: 'Size (KB)', + }, + {key: 'wastedMs', itemType: 'ms', text: 'Download Time (ms)', granularity: 1}, + ]; + + const summary = {wastedMs}; + const details = Audit.makeTableDetails(headings, results, summary); + + return { + displayValue, + score: ByteEfficiencyAudit.scoreForWastedMs(wastedMs), + rawValue: wastedMs, + details, + }; + } +} + +module.exports = RenderBlockingResources; diff --git a/lighthouse-core/audits/dobetterweb/link-blocking-first-paint.js b/lighthouse-core/audits/dobetterweb/link-blocking-first-paint.js deleted file mode 100644 index 8b8a3f21822b..000000000000 --- a/lighthouse-core/audits/dobetterweb/link-blocking-first-paint.js +++ /dev/null @@ -1,120 +0,0 @@ -/** - * @license Copyright 2016 Google Inc. All Rights Reserved. - * Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. You may obtain a copy of the License at http://www.apache.org/licenses/LICENSE-2.0 - * Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions and limitations under the License. - */ - -/** - * @fileoverview Audit a page to see if it does not use that block first paint. - */ - -'use strict'; - -const Audit = require('../audit'); -const Util = require('../../report/html/renderer/util.js'); -const ByteEfficiencyAudit = require('../byte-efficiency/byte-efficiency-audit'); - -// Because of the way we detect blocking stylesheets, asynchronously loaded -// CSS with link[rel=preload] and an onload handler (see https://github.com/filamentgroup/loadCSS) -// can be falsely flagged as blocking. Therefore, ignore stylesheets that loaded fast enough -// to possibly be non-blocking (and they have minimal impact anyway). -const LOAD_THRESHOLD_IN_MS = 50; - -class LinkBlockingFirstPaintAudit extends Audit { - /** - * @return {!AuditMeta} - */ - static get meta() { - return { - name: 'link-blocking-first-paint', - description: 'Reduce render-blocking stylesheets', - informative: true, - scoreDisplayMode: Audit.SCORING_MODES.NUMERIC, - helpText: 'External stylesheets are blocking the first paint of your page. Consider ' + - 'delivering critical CSS via `