/** * @license * Copyright 2020 Google LLC * SPDX-License-Identifier: Apache-2.0 */ import {Audit} from './audit.js'; import * as i18n from '../lib/i18n/i18n.js'; import {NetworkRequest} from '../lib/network-request.js'; import {MainResource} from '../computed/main-resource.js'; import {LanternLargestContentfulPaint} from '../computed/metrics/lantern-largest-contentful-paint.js'; import {LoadSimulator} from '../computed/load-simulator.js'; import {LCPImageRecord} from '../computed/lcp-image-record.js'; const UIStrings = { /** Title of a lighthouse audit that tells a user to preload an image in order to improve their LCP time. */ title: 'Preload Largest Contentful Paint image', /** Description of a lighthouse audit that tells a user to preload an image in order to improve their LCP time. */ description: 'If the LCP element is dynamically added to the page, you should preload the ' + 'image in order to improve LCP. [Learn more about preloading LCP elements](https://web.dev/articles/optimize-lcp#optimize_when_the_resource_is_discovered).', }; const str_ = i18n.createIcuMessageFn(import.meta.url, UIStrings); /** * @typedef {LH.Crdp.Network.Initiator['type']|'redirect'|'fallbackToMain'} InitiatorType * @typedef {Array<{url: string, initiatorType: InitiatorType}>} InitiatorPath */ class PrioritizeLcpImage extends Audit { /** * @return {LH.Audit.Meta} */ static get meta() { return { id: 'prioritize-lcp-image', title: str_(UIStrings.title), description: str_(UIStrings.description), supportedModes: ['navigation'], guidanceLevel: 4, requiredArtifacts: ['Trace', 'DevtoolsLog', 'GatherContext', 'URL', 'TraceElements', 'SourceMaps'], scoreDisplayMode: Audit.SCORING_MODES.METRIC_SAVINGS, }; } /** * * @param {LH.Artifacts.NetworkRequest} request * @param {LH.Artifacts.NetworkRequest} mainResource * @param {InitiatorPath} initiatorPath * @return {boolean} */ static shouldPreloadRequest(request, mainResource, initiatorPath) { // If it's already preloaded, no need to recommend it. if (request.isLinkPreload) return false; // It's not a request loaded over the network, don't recommend it. if (NetworkRequest.isNonNetworkRequest(request)) return false; // It's already discoverable from the main document (a path of [lcpRecord, mainResource]), don't recommend it. if (initiatorPath.length <= 2) return false; // Finally, return whether or not it belongs to the main frame return request.frameId === mainResource.frameId; } /** * @param {LH.Gatherer.Simulation.GraphNode} graph * @param {NetworkRequest} lcpRecord * @return {LH.Gatherer.Simulation.GraphNetworkNode|undefined} */ static findLCPNode(graph, lcpRecord) { for (const {node} of graph.traverseGenerator()) { if (node.type !== 'network') continue; if (node.request.requestId === lcpRecord.requestId) { return node; } } } /** * Get the initiator path starting with lcpRecord back to mainResource, inclusive. * Navigation redirects *to* the mainResource are not included. * Path returned will always be at least [lcpRecord, mainResource]. * @param {NetworkRequest} lcpRecord * @param {NetworkRequest} mainResource * @return {InitiatorPath} */ static getLcpInitiatorPath(lcpRecord, mainResource) { /** @type {InitiatorPath} */ const initiatorPath = []; let mainResourceReached = false; /** @type {NetworkRequest|undefined} */ let request = lcpRecord; while (request) { mainResourceReached ||= request.requestId === mainResource.requestId; /** @type {InitiatorType} */ let initiatorType = request.initiator?.type ?? 'other'; // Initiator type usually comes from redirect, but 'redirect' is used for more informative debugData. if (request.initiatorRequest && request.initiatorRequest === request.redirectSource) { initiatorType = 'redirect'; } // Sometimes the initiator chain is broken and the best that can be done is stitch // back to the main resource. Note this in the initiatorType. if (!request.initiatorRequest && !mainResourceReached) { initiatorType = 'fallbackToMain'; } initiatorPath.push({url: request.url, initiatorType}); // Can't preload before the main resource, so break off initiator path there. if (mainResourceReached) break; // Continue up chain, falling back to mainResource if chain is broken. request = request.initiatorRequest || mainResource; } return initiatorPath; } /** * @param {LH.Artifacts.NetworkRequest} mainResource * @param {LH.Gatherer.Simulation.GraphNode} graph * @param {NetworkRequest|undefined} lcpRecord * @return {{lcpNodeToPreload?: LH.Gatherer.Simulation.GraphNetworkNode, initiatorPath?: InitiatorPath}} */ static getLCPNodeToPreload(mainResource, graph, lcpRecord) { if (!lcpRecord) return {}; const lcpNode = PrioritizeLcpImage.findLCPNode(graph, lcpRecord); const initiatorPath = PrioritizeLcpImage.getLcpInitiatorPath(lcpRecord, mainResource); if (!lcpNode) return {initiatorPath}; // eslint-disable-next-line max-len const shouldPreload = PrioritizeLcpImage.shouldPreloadRequest(lcpRecord, mainResource, initiatorPath); const lcpNodeToPreload = shouldPreload ? lcpNode : undefined; return { lcpNodeToPreload, initiatorPath, }; } /** * Computes the estimated effect of preloading the LCP image. * @param {LH.Artifacts.TraceElement} lcpElement * @param {LH.Gatherer.Simulation.GraphNetworkNode|undefined} lcpNode * @param {LH.Gatherer.Simulation.GraphNode} graph * @param {LH.Gatherer.Simulation.Simulator} simulator * @return {{wastedMs: number, results: Array<{node: LH.Audit.Details.NodeValue, url: string, wastedMs: number}>}} */ static computeWasteWithGraph(lcpElement, lcpNode, graph, simulator) { if (!lcpNode) { return { wastedMs: 0, results: [], }; } const modifiedGraph = graph.cloneWithRelationships(); // Store the IDs of the LCP Node's dependencies for later /** @type {Set} */ const dependenciesIds = new Set(); for (const node of lcpNode.getDependencies()) { dependenciesIds.add(node.id); } /** @type {LH.Gatherer.Simulation.GraphNode|null} */ let modifiedLCPNode = null; /** @type {LH.Gatherer.Simulation.GraphNode|null} */ let mainDocumentNode = null; for (const {node} of modifiedGraph.traverseGenerator()) { if (node.type !== 'network') continue; if (node.isMainDocument()) { mainDocumentNode = node; } else if (node.id === lcpNode.id) { modifiedLCPNode = node; } } if (!mainDocumentNode) { // Should always find the main document node throw new Error('Could not find main document node'); } if (!modifiedLCPNode) { // Should always find the LCP node as well or else this function wouldn't have been called throw new Error('Could not find the LCP node'); } // Preload will request the resource as soon as its discovered in the main document. // Reflect this change in the dependencies in our modified graph. modifiedLCPNode.removeAllDependencies(); modifiedLCPNode.addDependency(mainDocumentNode); const simulationBeforeChanges = simulator.simulate(graph); const simulationAfterChanges = simulator.simulate(modifiedGraph); const lcpTimingsBefore = simulationBeforeChanges.nodeTimings.get(lcpNode); if (!lcpTimingsBefore) throw new Error('Impossible - node timings should never be undefined'); const lcpTimingsAfter = simulationAfterChanges.nodeTimings.get(modifiedLCPNode); if (!lcpTimingsAfter) throw new Error('Impossible - node timings should never be undefined'); /** @type {Map} */ const modifiedNodesById = Array.from(simulationAfterChanges.nodeTimings.keys()) .reduce((map, node) => map.set(node.id, node), new Map()); // Even with preload, the image can't be painted before it's even inserted into the DOM. // New LCP time will be the max of image download and image in DOM (endTime of its deps). let maxDependencyEndTime = 0; for (const nodeId of Array.from(dependenciesIds)) { const node = modifiedNodesById.get(nodeId); if (!node) throw new Error('Impossible - node should never be undefined'); const timings = simulationAfterChanges.nodeTimings.get(node); const endTime = timings?.endTime || 0; maxDependencyEndTime = Math.max(maxDependencyEndTime, endTime); } const wastedMs = lcpTimingsBefore.endTime - Math.max(lcpTimingsAfter.endTime, maxDependencyEndTime); return { wastedMs, results: [{ node: Audit.makeNodeItem(lcpElement.node), url: lcpNode.request.url, wastedMs, }], }; } /** * @param {LH.Artifacts} artifacts * @param {LH.Audit.Context} context * @return {Promise} */ static async audit(artifacts, context) { const gatherContext = artifacts.GatherContext; const trace = artifacts.Trace; const devtoolsLog = artifacts.DevtoolsLog; const {URL, SourceMaps} = artifacts; const settings = context.settings; const metricData = {trace, devtoolsLog, gatherContext, settings, URL, SourceMaps, simulator: null}; const lcpElement = artifacts.TraceElements .find(element => element.traceEventType === 'largest-contentful-paint'); if (!lcpElement || lcpElement.type !== 'image') { return {score: null, notApplicable: true, metricSavings: {LCP: 0}}; } const mainResource = await MainResource.request({devtoolsLog, URL}, context); const lanternLCP = await LanternLargestContentfulPaint.request(metricData, context); const simulator = await LoadSimulator.request({devtoolsLog, settings}, context); const lcpImageRecord = await LCPImageRecord.request({trace, devtoolsLog}, context); const graph = lanternLCP.pessimisticGraph; // Note: if moving to LCPAllFrames, mainResource would need to be the LCP frame's main resource. const {lcpNodeToPreload, initiatorPath} = PrioritizeLcpImage.getLCPNodeToPreload(mainResource, graph, lcpImageRecord); const {results, wastedMs} = PrioritizeLcpImage.computeWasteWithGraph(lcpElement, lcpNodeToPreload, graph, simulator); /** @type {LH.Audit.Details.Opportunity['headings']} */ const headings = [ {key: 'node', valueType: 'node', label: ''}, {key: 'url', valueType: 'url', label: str_(i18n.UIStrings.columnURL)}, {key: 'wastedMs', valueType: 'timespanMs', label: str_(i18n.UIStrings.columnWastedMs)}, ]; const details = Audit.makeOpportunityDetails(headings, results, {overallSavingsMs: wastedMs, sortedBy: ['wastedMs']}); // If LCP element was an image and had valid network records (regardless of // if it should be preloaded), it will be found first in the `initiatorPath`. // Otherwise path and length will be undefined. if (initiatorPath) { details.debugData = { type: 'debugdata', initiatorPath, pathLength: initiatorPath.length, }; } return { score: results.length ? 0 : 1, numericValue: wastedMs, numericUnit: 'millisecond', displayValue: wastedMs ? str_(i18n.UIStrings.displayValueMsSavings, {wastedMs}) : '', details, metricSavings: {LCP: wastedMs}, }; } } export default PrioritizeLcpImage; export {UIStrings};