UNPKG

chrome-devtools-frontend

Version:
246 lines (207 loc) • 9.54 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 i18n from '../../../core/i18n/i18n.js'; import * as Protocol from '../../../generated/protocol.js'; import * as Handlers from '../handlers/handlers.js'; import * as Helpers from '../helpers/helpers.js'; import type * as Lantern from '../lantern/lantern.js'; import type * as Types from '../types/types.js'; import { InsightCategory, InsightKeys, type InsightModel, type InsightSetContext, type InsightSetContextWithNavigation, InsightWarning, type LanternContext, type PartialInsightModel, } from './types.js'; export const UIStrings = { /** * @description Title of an insight that provides the user with the list of network requests that blocked and therefore slowed down the page rendering and becoming visible to the user. */ title: 'Render blocking requests', /** * @description Text to describe that there are requests blocking rendering, which may affect LCP. */ description: 'Requests are blocking the page\'s initial render, which may delay LCP. ' + '[Deferring or inlining](https://web.dev/learn/performance/understanding-the-critical-path#render-blocking_resources) ' + 'can move these network requests out of the critical path.', /** * @description Label to describe a network request (that happens to be render-blocking). */ renderBlockingRequest: 'Request', /** *@description Label used for a time duration. */ duration: 'Duration', /** * @description Text status indicating that no requests blocked the initial render of a navigation */ noRenderBlocking: 'No render blocking requests for this navigation', } as const; const str_ = i18n.i18n.registerUIStrings('models/trace/insights/RenderBlocking.ts', UIStrings); export const i18nString = i18n.i18n.getLocalizedString.bind(undefined, str_); export function isRenderBlocking(insight: InsightModel): insight is RenderBlockingInsightModel { return insight.insightKey === 'RenderBlocking'; } export type RenderBlockingInsightModel = InsightModel<typeof UIStrings, { renderBlockingRequests: Types.Events.SyntheticNetworkRequest[], requestIdToWastedMs?: Map<string, number>, }>; // 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 nodeTimings, return an object with the nodes/timing keyed by network URL */ function getNodesAndTimingByRequestId(nodeTimings: Lantern.Simulation.Result['nodeTimings']): Map<string, {node: Lantern.Graph.Node, nodeTiming: Lantern.Types.Simulation.NodeTiming}> { const requestIdToNode = new Map<string, {node: Lantern.Graph.Node, nodeTiming: Lantern.Types.Simulation.NodeTiming}>(); for (const [node, nodeTiming] of nodeTimings) { if (node.type !== 'network') { continue; } requestIdToNode.set(node.request.requestId, {node, nodeTiming}); } return requestIdToNode; } function estimateSavingsWithGraphs(deferredIds: Set<string>, lanternContext: LanternContext): Types.Timing.Milli { const simulator = lanternContext.simulator; const fcpGraph = lanternContext.metrics.firstContentfulPaint.optimisticGraph; const {nodeTimings} = lanternContext.simulator.simulate(fcpGraph); const adjustedNodeTimings = new Map(nodeTimings); const totalChildNetworkBytes = 0; const minimalFCPGraph = fcpGraph.cloneWithRelationships(node => { // If a node can be deferred, exclude it from the new FCP graph const canDeferRequest = deferredIds.has(node.id); return !canDeferRequest; }); if (minimalFCPGraph.type !== 'network') { throw new Error('minimalFCPGraph not a NetworkNode'); } // Recalculate the "before" time based on our adjusted node timings. const estimateBeforeInline = Math.max(...Array.from( Array.from(adjustedNodeTimings).map(timing => timing[1].endTime), )); // Add the inlined bytes to the HTML response const originalTransferSize = minimalFCPGraph.request.transferSize; const safeTransferSize = originalTransferSize || 0; minimalFCPGraph.request.transferSize = safeTransferSize + totalChildNetworkBytes; const estimateAfterInline = simulator.simulate(minimalFCPGraph).timeInMs; minimalFCPGraph.request.transferSize = originalTransferSize; return Math.round(Math.max(estimateBeforeInline - estimateAfterInline, 0)) as Types.Timing.Milli; } function hasImageLCP(parsedTrace: Handlers.Types.ParsedTrace, context: InsightSetContextWithNavigation): boolean { return parsedTrace.LargestImagePaint.lcpRequestByNavigationId.has(context.navigationId); } function computeSavings( parsedTrace: Handlers.Types.ParsedTrace, context: InsightSetContextWithNavigation, renderBlockingRequests: Types.Events.SyntheticNetworkRequest[]): Pick<RenderBlockingInsightModel, 'metricSavings'|'requestIdToWastedMs'>|undefined { if (!context.lantern) { return; } const nodesAndTimingsByRequestId = getNodesAndTimingByRequestId(context.lantern.metrics.firstContentfulPaint.optimisticEstimate.nodeTimings); const metricSavings = {FCP: 0 as Types.Timing.Milli, LCP: 0 as Types.Timing.Milli}; const requestIdToWastedMs = new Map<string, number>(); const deferredNodeIds = new Set<string>(); for (const request of renderBlockingRequests) { const nodeAndTiming = nodesAndTimingsByRequestId.get(request.args.data.requestId); if (!nodeAndTiming) { continue; } const {node, nodeTiming} = nodeAndTiming; // Mark this node and all its dependents as deferrable node.traverse(node => deferredNodeIds.add(node.id)); // "wastedMs" is the download time of the network request, responseReceived - requestSent const wastedMs = Math.round(nodeTiming.duration); if (wastedMs < MINIMUM_WASTED_MS) { continue; } requestIdToWastedMs.set(node.id, wastedMs); } if (requestIdToWastedMs.size) { metricSavings.FCP = estimateSavingsWithGraphs(deferredNodeIds, context.lantern); // In most cases, render blocking resources only affect LCP if LCP isn't an image. if (!hasImageLCP(parsedTrace, context)) { metricSavings.LCP = metricSavings.FCP; } } return {metricSavings, requestIdToWastedMs}; } function finalize(partialModel: PartialInsightModel<RenderBlockingInsightModel>): RenderBlockingInsightModel { return { insightKey: InsightKeys.RENDER_BLOCKING, strings: UIStrings, title: i18nString(UIStrings.title), description: i18nString(UIStrings.description), category: InsightCategory.LCP, state: partialModel.renderBlockingRequests.length > 0 ? 'fail' : 'pass', ...partialModel, }; } export function generateInsight( parsedTrace: Handlers.Types.ParsedTrace, context: InsightSetContext): RenderBlockingInsightModel { if (!context.navigation) { return finalize({ renderBlockingRequests: [], }); } const firstPaintTs = parsedTrace.PageLoadMetrics.metricScoresByFrameId.get(context.frameId) ?.get(context.navigationId) ?.get(Handlers.ModelHandlers.PageLoadMetrics.MetricName.FP) ?.event?.ts; if (!firstPaintTs) { return finalize({ renderBlockingRequests: [], warnings: [InsightWarning.NO_FP], }); } let renderBlockingRequests: Types.Events.SyntheticNetworkRequest[] = []; for (const req of parsedTrace.NetworkRequests.byTime) { if (req.args.data.frame !== context.frameId) { continue; } if (!Helpers.Network.isSyntheticNetworkRequestEventRenderBlocking(req)) { continue; } if (req.args.data.syntheticData.finishTime > firstPaintTs) { continue; } // If a request is marked `in_body_parser_blocking` it should only be considered render blocking if it is a // high enough priority. Some requests (e.g. scripts) are not marked as high priority if they are fetched // after a non-preloaded image. (See "early" definition in https://web.dev/articles/fetch-priority) // // There are edge cases and exceptions (e.g. priority hints) but this gives us the best approximation // of render blocking resources in the document body. if (req.args.data.renderBlocking === 'in_body_parser_blocking') { const priority = req.args.data.priority; const isScript = req.args.data.resourceType === Protocol.Network.ResourceType.Script; const isBlockingScript = isScript && priority === Protocol.Network.ResourcePriority.High; if (priority !== Protocol.Network.ResourcePriority.VeryHigh && !isBlockingScript) { continue; } } const navigation = Helpers.Trace.getNavigationForTraceEvent(req, context.frameId, parsedTrace.Meta.navigationsByFrameId); if (navigation === context.navigation) { renderBlockingRequests.push(req); } } const savings = computeSavings(parsedTrace, context, renderBlockingRequests); // Sort by request duration for insights. renderBlockingRequests = renderBlockingRequests.sort((a, b) => { return b.dur - a.dur; }); return finalize({ relatedEvents: renderBlockingRequests, renderBlockingRequests, ...savings, }); }