UNPKG

chrome-devtools-frontend

Version:
219 lines (196 loc) • 7.84 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 Handlers from '../handlers/handlers.js'; import * as Helpers from '../helpers/helpers.js'; import * as Types from '../types/types.js'; import { InsightCategory, type InsightModel, type InsightSetContext, InsightWarning, type PartialInsightModel, type RequiredData, } from './types.js'; export const UIStrings = { /** *@description Title of an insight that provides details about the LCP metric, broken down by phases / parts. */ title: 'LCP by phase', /** * @description Description of a DevTools insight that presents a breakdown for the LCP metric by phases. * This is displayed after a user expands the section to see more. No character length limits. */ description: 'Each [phase has specific improvement strategies](https://web.dev/articles/optimize-lcp#lcp-breakdown). Ideally, most of the LCP time should be spent on loading the resources, not within delays.', /** *@description Time to first byte title for the Largest Contentful Paint's phases timespan breakdown. */ timeToFirstByte: 'Time to first byte', /** *@description Resource load delay title for the Largest Contentful Paint phases timespan breakdown. */ resourceLoadDelay: 'Resource load delay', /** *@description Resource load duration title for the Largest Contentful Paint phases timespan breakdown. */ resourceLoadDuration: 'Resource load duration', /** *@description Element render delay title for the Largest Contentful Paint phases timespan breakdown. */ elementRenderDelay: 'Element render delay', /** *@description Label used for the phase/component/stage/section of a larger duration. */ phase: 'Phase', /** *@description Label used for the percentage a single phase/component/stage/section takes up of a larger duration. */ percentLCP: '% of LCP', /** * @description Text status indicating that the the Largest Contentful Paint (LCP) metric timing was not found. "LCP" is an acronym and should not be translated. */ noLcp: 'No LCP detected', }; const str_ = i18n.i18n.registerUIStrings('models/trace/insights/LCPPhases.ts', UIStrings); export const i18nString = i18n.i18n.getLocalizedString.bind(undefined, str_); export function deps(): ['NetworkRequests', 'PageLoadMetrics', 'LargestImagePaint', 'Meta'] { return ['NetworkRequests', 'PageLoadMetrics', 'LargestImagePaint', 'Meta']; } interface LCPPhases { /** * The time between when the user initiates loading the page until when * the browser receives the first byte of the html response. */ ttfb: Types.Timing.Milli; /** * The time between ttfb and the LCP request request being started. * For a text LCP, this is undefined given no request is loaded. */ loadDelay?: Types.Timing.Milli; /** * The time it takes to load the LCP request. */ loadTime?: Types.Timing.Milli; /** * The time between when the LCP request finishes loading and when * the LCP element is rendered. */ renderDelay: Types.Timing.Milli; } export type LCPPhasesInsightModel = InsightModel<typeof UIStrings, { lcpMs?: Types.Timing.Milli, lcpTs?: Types.Timing.Milli, lcpEvent?: Types.Events.LargestContentfulPaintCandidate, /** The network request for the LCP image, if there was one. */ lcpRequest?: Types.Events.SyntheticNetworkRequest, phases?: LCPPhases, }>; function anyValuesNaN(...values: number[]): boolean { return values.some(v => Number.isNaN(v)); } /** * Calculates the 4 phases of an LCP and the timings of each. * Will return `null` if any required values were missing. We don't ever expect * them to be missing on newer traces, but old trace files may lack some of the * data we rely on, so we want to handle that case. */ function breakdownPhases( nav: Types.Events.NavigationStart, docRequest: Types.Events.SyntheticNetworkRequest, lcpMs: Types.Timing.Milli, lcpRequest: Types.Events.SyntheticNetworkRequest|undefined): LCPPhases|null { const docReqTiming = docRequest.args.data.timing; if (!docReqTiming) { throw new Error('no timing for document request'); } const firstDocByteTs = Helpers.Timing.secondsToMicro(docReqTiming.requestTime) + Helpers.Timing.milliToMicro(docReqTiming.receiveHeadersStart); const firstDocByteTiming = Types.Timing.Micro(firstDocByteTs - nav.ts); const ttfb = Helpers.Timing.microToMilli(firstDocByteTiming); let renderDelay = Types.Timing.Milli(lcpMs - ttfb); if (!lcpRequest) { if (anyValuesNaN(ttfb, renderDelay)) { return null; } return {ttfb, renderDelay}; } const lcpStartTs = Types.Timing.Micro(lcpRequest.ts - nav.ts); const requestStart = Helpers.Timing.microToMilli(lcpStartTs); const lcpReqEndTs = Types.Timing.Micro(lcpRequest.args.data.syntheticData.finishTime - nav.ts); const requestEnd = Helpers.Timing.microToMilli(lcpReqEndTs); const loadDelay = Types.Timing.Milli(requestStart - ttfb); const loadTime = Types.Timing.Milli(requestEnd - requestStart); renderDelay = Types.Timing.Milli(lcpMs - requestEnd); if (anyValuesNaN(ttfb, loadDelay, loadTime, renderDelay)) { return null; } return { ttfb, loadDelay, loadTime, renderDelay, }; } function finalize(partialModel: PartialInsightModel<LCPPhasesInsightModel>): LCPPhasesInsightModel { const relatedEvents = []; if (partialModel.lcpEvent) { relatedEvents.push(partialModel.lcpEvent); } if (partialModel.lcpRequest) { relatedEvents.push(partialModel.lcpRequest); } return { strings: UIStrings, title: i18nString(UIStrings.title), description: i18nString(UIStrings.description), category: InsightCategory.LCP, state: partialModel.lcpEvent || partialModel.lcpRequest ? 'informative' : 'pass', ...partialModel, relatedEvents, }; } export function generateInsight( parsedTrace: RequiredData<typeof deps>, context: InsightSetContext): LCPPhasesInsightModel { if (!context.navigation) { return finalize({}); } const networkRequests = parsedTrace.NetworkRequests; const frameMetrics = parsedTrace.PageLoadMetrics.metricScoresByFrameId.get(context.frameId); if (!frameMetrics) { throw new Error('no frame metrics'); } const navMetrics = frameMetrics.get(context.navigationId); if (!navMetrics) { throw new Error('no navigation metrics'); } const metricScore = navMetrics.get(Handlers.ModelHandlers.PageLoadMetrics.MetricName.LCP); const lcpEvent = metricScore?.event; if (!lcpEvent || !Types.Events.isLargestContentfulPaintCandidate(lcpEvent)) { return finalize({warnings: [InsightWarning.NO_LCP]}); } // This helps calculate the phases. const lcpMs = Helpers.Timing.microToMilli(metricScore.timing); // This helps position things on the timeline's UI accurately for a trace. const lcpTs = metricScore.event?.ts ? Helpers.Timing.microToMilli(metricScore.event?.ts) : undefined; const lcpRequest = parsedTrace.LargestImagePaint.lcpRequestByNavigation.get(context.navigation); const docRequest = networkRequests.byTime.find(req => req.args.data.requestId === context.navigationId); if (!docRequest) { return finalize({lcpMs, lcpTs, lcpEvent, lcpRequest, warnings: [InsightWarning.NO_DOCUMENT_REQUEST]}); } if (!lcpRequest) { return finalize({ lcpMs, lcpTs, lcpEvent, lcpRequest, phases: breakdownPhases(context.navigation, docRequest, lcpMs, lcpRequest) ?? undefined, }); } return finalize({ lcpMs, lcpTs, lcpEvent, lcpRequest, phases: breakdownPhases(context.navigation, docRequest, lcpMs, lcpRequest) ?? undefined, }); }