// 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 Handlers from '../handlers/handlers.js'; import * as Helpers from '../helpers/helpers.js'; import { InsightCategory, InsightKeys, InsightWarning, } 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', }; // const str_ = i18n.i18n.registerUIStrings('models/trace/insights/RenderBlocking.ts', UIStrings); export const i18nString = (i18nId, values) => ({i18nId, values}); // i18n.i18n.getLocalizedString.bind(undefined, str_); export function isRenderBlocking(insight) { return insight.insightKey === 'RenderBlocking'; } // 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) { const requestIdToNode = new Map(); for (const [node, nodeTiming] of nodeTimings) { if (node.type !== 'network') { continue; } requestIdToNode.set(node.request.requestId, { node, nodeTiming }); } return requestIdToNode; } function estimateSavingsWithGraphs(deferredIds, lanternContext) { 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)); } function hasImageLCP(parsedTrace, context) { return parsedTrace.LargestImagePaint.lcpRequestByNavigationId.has(context.navigationId); } function computeSavings(parsedTrace, context, renderBlockingRequests) { if (!context.lantern) { return; } const nodesAndTimingsByRequestId = getNodesAndTimingByRequestId(context.lantern.metrics.firstContentfulPaint.optimisticEstimate.nodeTimings); const metricSavings = { FCP: 0, LCP: 0 }; const requestIdToWastedMs = new Map(); const deferredNodeIds = new Set(); 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) { 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, context) { 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 = []; 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 === "Script" /* Protocol.Network.ResourceType.Script */; const isBlockingScript = isScript && priority === "High" /* Protocol.Network.ResourcePriority.High */; if (priority !== "VeryHigh" /* 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, }); } export function createOverlayForRequest(request) { return { type: 'ENTRY_OUTLINE', entry: request, outlineReason: 'ERROR', }; } export function createOverlays(model) { return model.renderBlockingRequests.map(request => createOverlayForRequest(request)); } //# sourceMappingURL=RenderBlocking.js.map