UNPKG

chrome-devtools-frontend

Version:
188 lines (164 loc) • 8.22 kB
// Copyright 2024 The Chromium Authors. All rights reserved. // Use of this source code is governed by a BSD-style license that can be // found in the LICENSE file. import * as Graph from '../graph/graph.js'; import type * as Types from '../types/types.js'; import {Metric, type MetricCoefficients} from './Metric.js'; interface FirstPaintBasedGraphOpts<T> { /** * The timestamp used to filter out tasks that occurred after our paint of interest. * Typically this is First Contentful Paint or First Meaningful Paint. */ cutoffTimestamp: number; /** * The function that determines which resources should be considered *possibly* * render-blocking. */ treatNodeAsRenderBlocking: (node: Graph.NetworkNode<T>) => boolean; /** * The function that determines which CPU nodes should also be included in our * blocking node IDs set, beyond what getRenderBlockingNodeData() already includes. */ additionalCpuNodesToTreatAsRenderBlocking?: (node: Graph.CPUNode) => boolean; } class FirstContentfulPaint extends Metric { static override get coefficients(): MetricCoefficients { return { intercept: 0, optimistic: 0.5, pessimistic: 0.5, }; } /** * Computes the set of URLs that *appeared* to be render-blocking based on our filter, * *but definitely were not* render-blocking based on the timing of their EvaluateScript task. * It also computes the set of corresponding CPU node ids that were needed for the paint at the * given timestamp. */ static getRenderBlockingNodeData<T = unknown>( graph: Graph.Node, {cutoffTimestamp, treatNodeAsRenderBlocking, additionalCpuNodesToTreatAsRenderBlocking}: FirstPaintBasedGraphOpts<T>, ): {definitelyNotRenderBlockingScriptUrls: Set<string>, renderBlockingCpuNodeIds: Set<string>} { /** A map of blocking script URLs to the earliest EvaluateScript task node that executed them. */ const scriptUrlToNodeMap = new Map<string, Graph.CPUNode>(); const cpuNodes: Graph.CPUNode[] = []; graph.traverse(node => { if (node.type === Graph.BaseNode.types.CPU) { // A task is *possibly* render blocking if it *started* before cutoffTimestamp. // We use startTime here because the paint event can be *inside* the task that was render blocking. if (node.startTime <= cutoffTimestamp) { cpuNodes.push(node); } // Build our script URL map to find the earliest EvaluateScript task node. const scriptUrls = node.getEvaluateScriptURLs(); for (const url of scriptUrls) { // Use the earliest CPU node we find. const existing = scriptUrlToNodeMap.get(url) || node; scriptUrlToNodeMap.set(url, node.startTime < existing.startTime ? node : existing); } } }); cpuNodes.sort((a, b) => a.startTime - b.startTime); // A script is *possibly* render blocking if it finished loading before cutoffTimestamp. const possiblyRenderBlockingScriptUrls = Metric.getScriptUrls(graph, node => { // The optimistic LCP treatNodeAsRenderBlocking fn wants to exclude some images in the graph, // but here it only receives scripts to evaluate. It's a no-op in this case, but it will // matter below in the getFirstPaintBasedGraph clone operation. return node.endTime <= cutoffTimestamp && treatNodeAsRenderBlocking(node); }); // A script is *definitely not* render blocking if its EvaluateScript task started after cutoffTimestamp. const definitelyNotRenderBlockingScriptUrls = new Set<string>(); const renderBlockingCpuNodeIds = new Set<string>(); for (const url of possiblyRenderBlockingScriptUrls) { // Lookup the CPU node that had the earliest EvaluateScript for this URL. const cpuNodeForUrl = scriptUrlToNodeMap.get(url); // If we can't find it at all, we can't conclude anything, so just skip it. if (!cpuNodeForUrl) { continue; } // If we found it and it was in our `cpuNodes` set that means it finished before cutoffTimestamp, so it really is render-blocking. if (cpuNodes.includes(cpuNodeForUrl)) { renderBlockingCpuNodeIds.add(cpuNodeForUrl.id); continue; } // We couldn't find the evaluate script in the set of CPU nodes that ran before our paint, so // it must not have been necessary for the paint. definitelyNotRenderBlockingScriptUrls.add(url); } // The first layout, first paint, and first ParseHTML are almost always necessary for first paint, // so we always include those CPU nodes. const firstLayout = cpuNodes.find(node => node.didPerformLayout()); if (firstLayout) { renderBlockingCpuNodeIds.add(firstLayout.id); } const firstPaint = cpuNodes.find(node => node.childEvents.some(e => e.name === 'Paint')); if (firstPaint) { renderBlockingCpuNodeIds.add(firstPaint.id); } const firstParse = cpuNodes.find(node => node.childEvents.some(e => e.name === 'ParseHTML')); if (firstParse) { renderBlockingCpuNodeIds.add(firstParse.id); } // If a CPU filter was passed in, we also want to include those extra nodes. if (additionalCpuNodesToTreatAsRenderBlocking) { cpuNodes.filter(additionalCpuNodesToTreatAsRenderBlocking).forEach(node => renderBlockingCpuNodeIds.add(node.id)); } return { definitelyNotRenderBlockingScriptUrls, renderBlockingCpuNodeIds, }; } /** * Computes the graph required for the first paint of interest. */ static getFirstPaintBasedGraph<T>( dependencyGraph: Graph.Node, {cutoffTimestamp, treatNodeAsRenderBlocking, additionalCpuNodesToTreatAsRenderBlocking}: FirstPaintBasedGraphOpts<T>, ): Graph.Node<T> { const rbData = this.getRenderBlockingNodeData(dependencyGraph, { cutoffTimestamp, treatNodeAsRenderBlocking, additionalCpuNodesToTreatAsRenderBlocking, }); const {definitelyNotRenderBlockingScriptUrls, renderBlockingCpuNodeIds} = rbData; return dependencyGraph.cloneWithRelationships(node => { if (node.type === Graph.BaseNode.types.NETWORK) { // Exclude all nodes that ended after cutoffTimestamp (except for the main document which we always consider necessary) // endTime is negative if request does not finish, make sure startTime isn't after cutoffTimestamp in this case. const endedAfterPaint = node.endTime > cutoffTimestamp || node.startTime > cutoffTimestamp; if (endedAfterPaint && !node.isMainDocument()) { return false; } const url = node.request.url; // If the URL definitely wasn't render-blocking then we filter it out. if (definitelyNotRenderBlockingScriptUrls.has(url)) { return false; } // Lastly, build up the FCP graph of all nodes we consider render blocking return treatNodeAsRenderBlocking(node); } // If it's a CPU node, just check if it was blocking. return renderBlockingCpuNodeIds.has(node.id); }); } static override getOptimisticGraph<T>( dependencyGraph: Graph.Node<T>, processedNavigation: Types.Simulation.ProcessedNavigation): Graph.Node<T> { return this.getFirstPaintBasedGraph(dependencyGraph, { cutoffTimestamp: processedNavigation.timestamps.firstContentfulPaint, // In the optimistic graph we exclude resources that appeared to be render blocking but were // initiated by a script. While they typically have a very high importance and tend to have a // significant impact on the page's content, these resources don't technically block rendering. treatNodeAsRenderBlocking: node => node.hasRenderBlockingPriority() && node.initiatorType !== 'script', }); } static override getPessimisticGraph<T>( dependencyGraph: Graph.Node<T>, processedNavigation: Types.Simulation.ProcessedNavigation): Graph.Node<T> { return this.getFirstPaintBasedGraph(dependencyGraph, { cutoffTimestamp: processedNavigation.timestamps.firstContentfulPaint, treatNodeAsRenderBlocking: node => node.hasRenderBlockingPriority(), }); } } export {FirstContentfulPaint};