-
Notifications
You must be signed in to change notification settings - Fork 9.4k
/
prioritize-lcp-image.js
295 lines (256 loc) · 11.4 KB
/
prioritize-lcp-image.js
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
/**
* @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: ['traces', 'devtoolsLogs', 'GatherContext', 'URL', 'TraceElements'],
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<string>} */
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<String, LH.Gatherer.Simulation.GraphNode>} */
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<LH.Audit.Product>}
*/
static async audit(artifacts, context) {
const gatherContext = artifacts.GatherContext;
const trace = artifacts.traces[PrioritizeLcpImage.DEFAULT_PASS];
const devtoolsLog = artifacts.devtoolsLogs[PrioritizeLcpImage.DEFAULT_PASS];
const URL = artifacts.URL;
const settings = context.settings;
const metricData = {trace, devtoolsLog, gatherContext, settings, URL};
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};