UNPKG

chrome-devtools-frontend

Version:
435 lines (379 loc) • 18.5 kB
// Copyright 2022 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 Helpers from '../helpers/helpers.js'; import {type TraceEventHandlerName, HandlerState} from './types.js'; import {ScoreClassification} from './PageLoadMetricsHandler.js'; import {data as metaHandlerData} from './MetaHandler.js'; import {data as screenshotsHandlerData} from './ScreenshotsHandler.js'; import * as Platform from '../../../core/platform/platform.js'; import * as Types from '../types/types.js'; // We start with a score of zero and step through all Layout Shift records from // all renderers. Each record not only tells us which renderer it is, but also // the unweighted and weighted scores. The unweighted score is the score we would // get if the renderer were the only one in the viewport. The weighted score, on // the other hand, accounts for how much of the viewport that particular render // takes up when the shift happened. An ad frame in the corner of the viewport // that shifts is considered less disruptive, therefore, than if it were taking // up the whole viewport. // // Next, we step through all the records from all renderers and add the weighted // score to a running total across all of the renderers. We create a new "cluster" // and reset the running total when: // // 1. We observe a outermost frame navigation, or // 2. When there's a gap between records of > 1s, or // 3. When there's more than 5 seconds of continuous layout shifting. // // Note that for it to be Cumulative Layout Shift in the sense described in the // documentation we would need to guarantee that we are tracking from navigation // to unload. However, we don't make any such guarantees here (since a developer // can record and stop when they please), so we support the cluster approach, // and we can give them a score, but it is effectively a "session" score, a // score for the given recording, and almost certainly not the // navigation-to-unload CLS score. interface LayoutShifts { clusters: LayoutShiftCluster[]; sessionMaxScore: number; // The session window which contains the SessionMaxScore clsWindowID: number; // We use these to calculate root causes for a given LayoutShift prePaintEvents: Types.TraceEvents.TraceEventPrePaint[]; layoutInvalidationEvents: Types.TraceEvents.TraceEventLayoutInvalidation[]; styleRecalcInvalidationEvents: Types.TraceEvents.TraceEventStyleRecalcInvalidation[]; scoreRecords: ScoreRecord[]; } // This represents the maximum #time we will allow a cluster to go before we // reset it. export const MAX_CLUSTER_DURATION = Helpers.Timing.millisecondsToMicroseconds(Types.Timing.MilliSeconds(5000)); // This represents the maximum #time we will allow between layout shift events // before considering it to be the start of a new cluster. export const MAX_SHIFT_TIME_DELTA = Helpers.Timing.millisecondsToMicroseconds(Types.Timing.MilliSeconds(1000)); // Layout shifts are reported globally to the developer, irrespective of which // frame they originated in. However, each process does have its own individual // CLS score, so we need to segment by process. This means Layout Shifts from // sites with one process (no subframes, or subframes from the same origin) // will be reported together. In the case of multiple renderers (frames across // different origins), we offer the developer the ability to switch renderer in // the UI. const layoutShiftEvents: Types.TraceEvents.TraceEventLayoutShift[] = []; // These events denote potential node resizings. We store them to link captured // layout shifts to the resizing of unsized elements. const layoutInvalidationEvents: Types.TraceEvents.TraceEventLayoutInvalidation[] = []; const styleRecalcInvalidationEvents: Types.TraceEvents.TraceEventStyleRecalcInvalidation[] = []; // Layout shifts happen during PrePaint as part of the rendering lifecycle. // We determine if a LayoutInvalidation event is a potential root cause of a layout // shift if the next PrePaint after the LayoutInvalidation is the parent // node of such shift. const prePaintEvents: Types.TraceEvents.TraceEventPrePaint[] = []; let sessionMaxScore = 0; let clsWindowID = -1; const clusters: LayoutShiftCluster[] = []; // Represents a point in time in which a LS score change // was recorded. type ScoreRecord = { ts: number, score: number, }; // The complete timeline of LS score changes in a trace. // Includes drops to 0 when session windows end. const scoreRecords: ScoreRecord[] = []; let handlerState = HandlerState.UNINITIALIZED; export function initialize(): void { if (handlerState !== HandlerState.UNINITIALIZED) { throw new Error('LayoutShifts Handler was not reset'); } handlerState = HandlerState.INITIALIZED; } export function reset(): void { handlerState = HandlerState.UNINITIALIZED; layoutShiftEvents.length = 0; layoutInvalidationEvents.length = 0; prePaintEvents.length = 0; clusters.length = 0; sessionMaxScore = 0; scoreRecords.length = 0; clsWindowID = -1; } export function handleEvent(event: Types.TraceEvents.TraceEventData): void { if (handlerState !== HandlerState.INITIALIZED) { throw new Error('Handler is not initialized'); } if (Types.TraceEvents.isTraceEventLayoutShift(event) && !event.args.data?.had_recent_input) { layoutShiftEvents.push(event); return; } if (Types.TraceEvents.isTraceEventLayoutInvalidation(event)) { layoutInvalidationEvents.push(event); return; } if (Types.TraceEvents.isTraceEventStyleRecalcInvalidation(event)) { styleRecalcInvalidationEvents.push(event); } if (Types.TraceEvents.isTraceEventPrePaint(event)) { prePaintEvents.push(event); return; } } function traceWindowFromTime(time: Types.Timing.MicroSeconds): Types.Timing.TraceWindow { return { min: time, max: time, range: Types.Timing.MicroSeconds(0), }; } function updateTraceWindowMax(traceWindow: Types.Timing.TraceWindow, newMax: Types.Timing.MicroSeconds): void { traceWindow.max = newMax; traceWindow.range = Types.Timing.MicroSeconds(traceWindow.max - traceWindow.min); } function findNextScreenshotSource(timestamp: Types.Timing.MicroSeconds): string|undefined { const screenshots = screenshotsHandlerData(); const screenshotIndex = findNextScreenshotEventIndex(screenshots, timestamp); if (!screenshotIndex) { return undefined; } return `data:img/png;base64,${screenshots[screenshotIndex].args.snapshot}`; } export function findNextScreenshotEventIndex( screenshots: Types.TraceEvents.TraceEventSnapshot[], timestamp: Types.Timing.MicroSeconds): number|null { return Platform.ArrayUtilities.nearestIndexFromBeginning(screenshots, frame => frame.ts > timestamp); } function buildScoreRecords(): void { const {traceBounds} = metaHandlerData(); scoreRecords.push({ts: traceBounds.min, score: 0}); for (const cluster of clusters) { let clusterScore = 0; if (cluster.events[0].args.data) { scoreRecords.push({ts: cluster.clusterWindow.min, score: cluster.events[0].args.data.weighted_score_delta}); } for (let i = 0; i < cluster.events.length; i++) { const event = cluster.events[i]; if (!event.args.data) { continue; } clusterScore += event.args.data.weighted_score_delta; scoreRecords.push({ts: event.ts, score: clusterScore}); } scoreRecords.push({ts: cluster.clusterWindow.max, score: 0}); } } export async function finalize(): Promise<void> { // Ensure the events are sorted by #time ascending. layoutShiftEvents.sort((a, b) => a.ts - b.ts); prePaintEvents.sort((a, b) => a.ts - b.ts); layoutInvalidationEvents.sort((a, b) => a.ts - b.ts); // Each function transforms the data used by the next, as such the invoke order // is important. await buildLayoutShiftsClusters(); buildScoreRecords(); handlerState = HandlerState.FINALIZED; } async function buildLayoutShiftsClusters(): Promise<void> { const {navigationsByFrameId, mainFrameId, traceBounds} = metaHandlerData(); const navigations = navigationsByFrameId.get(mainFrameId) || []; if (layoutShiftEvents.length === 0) { return; } let firstShiftTime = layoutShiftEvents[0].ts; let lastShiftTime = layoutShiftEvents[0].ts; let lastShiftNavigation = null; // Now step through each and create clusters. // A cluster is equivalent to a session window (see https://web.dev/cls/#what-is-cls). // To make the line chart clear, we explicitly demark the limits of each session window // by starting the cumulative score of the window at the time of the first layout shift // and ending it (dropping the line back to 0) when the window ends according to the // thresholds (MAX_CLUSTER_DURATION, MAX_SHIFT_TIME_DELTA). for (const event of layoutShiftEvents) { // First detect if either the cluster duration or the #time between this and // the last shift has been exceeded. const clusterDurationExceeded = event.ts - firstShiftTime > MAX_CLUSTER_DURATION; const maxTimeDeltaSinceLastShiftExceeded = event.ts - lastShiftTime > MAX_SHIFT_TIME_DELTA; // Next take a look at navigations. If between this and the last shift we have navigated, // note it. const currentShiftNavigation = Platform.ArrayUtilities.nearestIndexFromEnd(navigations, nav => nav.ts < event.ts); const hasNavigated = lastShiftNavigation !== currentShiftNavigation && currentShiftNavigation !== null; // If any of the above criteria are met or if we don't have any cluster yet we should // start a new one. if (clusterDurationExceeded || maxTimeDeltaSinceLastShiftExceeded || hasNavigated || !clusters.length) { // The cluster starts #time should be the timestamp of the first layout shift in it. const clusterStartTime = event.ts; // If the last session window ended because the max delta time between shifts // was exceeded set the endtime to MAX_SHIFT_TIME_DELTA microseconds after the // last shift in the session. const endTimeByMaxSessionDuration = clusterDurationExceeded ? firstShiftTime + MAX_CLUSTER_DURATION : Infinity; // If the last session window ended because the max session duration was // surpassed, set the endtime so that the window length = MAX_CLUSTER_DURATION; const endTimeByMaxShiftGap = maxTimeDeltaSinceLastShiftExceeded ? lastShiftTime + MAX_SHIFT_TIME_DELTA : Infinity; // If there was a navigation during the last window, close it at the time // of the navigation. const endTimeByNavigation = hasNavigated ? navigations[currentShiftNavigation].ts : Infinity; // End the previous cluster at the time of the first of the criteria above that was met. const previousClusterEndTime = Math.min(endTimeByMaxSessionDuration, endTimeByMaxShiftGap, endTimeByNavigation); // If there is an existing cluster update its closing time. if (clusters.length > 0) { const currentCluster = clusters[clusters.length - 1]; updateTraceWindowMax(currentCluster.clusterWindow, Types.Timing.MicroSeconds(previousClusterEndTime)); } clusters.push({ events: [], clusterWindow: traceWindowFromTime(clusterStartTime), clusterCumulativeScore: 0, scoreWindows: { good: traceWindowFromTime(clusterStartTime), needsImprovement: null, bad: null, }, }); firstShiftTime = clusterStartTime; } // Given the above we should have a cluster available, so pick the most // recent one and append the shift, bump its score and window values accordingly. const currentCluster = clusters[clusters.length - 1]; const timeFromNavigation = currentShiftNavigation !== null ? Types.Timing.MicroSeconds(event.ts - navigations[currentShiftNavigation].ts) : undefined; currentCluster.clusterCumulativeScore += event.args.data ? event.args.data.weighted_score_delta : 0; if (!event.args.data) { continue; } const shift: Types.TraceEvents.SyntheticLayoutShift = { ...event, args: { frame: event.args.frame, data: { ...event.args.data, rawEvent: event, }, }, parsedData: { screenshotSource: findNextScreenshotSource(event.ts), timeFromNavigation, cumulativeWeightedScoreInWindow: currentCluster.clusterCumulativeScore, // The score of the session window is temporarily set to 0 just // to initialize it. Since we need to get the score of all shifts // in the session window to determine its value, its definite // value is set when stepping through the built clusters. sessionWindowData: {cumulativeWindowScore: 0, id: clusters.length}, }, }; currentCluster.events.push(shift); updateTraceWindowMax(currentCluster.clusterWindow, event.ts); lastShiftTime = event.ts; lastShiftNavigation = currentShiftNavigation; } // Now step through each cluster and set up the times at which the value // goes from Good, to needs improvement, to Bad. Note that if there is a // large jump we may go from Good to Bad without ever creating a Needs // Improvement window at all. for (const cluster of clusters) { let weightedScore = 0; let windowID = -1; // If this is the last cluster update its window. The cluster duration is determined // by the minimum between: time to next navigation, trace end time, time to maximum // cluster duration and time to maximum gap between layout shifts. if (cluster === clusters[clusters.length - 1]) { const clusterEndByMaxDuration = MAX_CLUSTER_DURATION + cluster.clusterWindow.min; const clusterEndByMaxGap = cluster.clusterWindow.max + MAX_SHIFT_TIME_DELTA; const nextNavigationIndex = Platform.ArrayUtilities.nearestIndexFromBeginning(navigations, nav => nav.ts > cluster.clusterWindow.max); const nextNavigationTime = nextNavigationIndex ? navigations[nextNavigationIndex].ts : Infinity; const clusterEnd = Math.min(clusterEndByMaxDuration, clusterEndByMaxGap, traceBounds.max, nextNavigationTime); updateTraceWindowMax(cluster.clusterWindow, Types.Timing.MicroSeconds(clusterEnd)); } for (const shift of cluster.events) { weightedScore += shift.args.data ? shift.args.data.weighted_score_delta : 0; windowID = shift.parsedData.sessionWindowData.id; const ts = shift.ts; // Update the the CLS score of this shift's session window now that // we have it. shift.parsedData.sessionWindowData.cumulativeWindowScore = cluster.clusterCumulativeScore; if (weightedScore < LayoutShiftsThreshold.NEEDS_IMPROVEMENT) { // Expand the Good window. updateTraceWindowMax(cluster.scoreWindows.good, ts); } else if ( weightedScore >= LayoutShiftsThreshold.NEEDS_IMPROVEMENT && weightedScore < LayoutShiftsThreshold.BAD) { if (!cluster.scoreWindows.needsImprovement) { // Close the Good window, and open the needs improvement window. updateTraceWindowMax(cluster.scoreWindows.good, Types.Timing.MicroSeconds(ts - 1)); cluster.scoreWindows.needsImprovement = traceWindowFromTime(ts); } // Expand the needs improvement window. updateTraceWindowMax(cluster.scoreWindows.needsImprovement, ts); } else if (weightedScore >= LayoutShiftsThreshold.BAD) { if (!cluster.scoreWindows.bad) { // We may jump from Good to Bad here, so update whichever window is open. if (cluster.scoreWindows.needsImprovement) { updateTraceWindowMax(cluster.scoreWindows.needsImprovement, Types.Timing.MicroSeconds(ts - 1)); } else { updateTraceWindowMax(cluster.scoreWindows.good, Types.Timing.MicroSeconds(ts - 1)); } cluster.scoreWindows.bad = traceWindowFromTime(shift.ts); } // Expand the Bad window. updateTraceWindowMax(cluster.scoreWindows.bad, ts); } // At this point the windows are set by the timestamps of the events, but the // next cluster begins at the timestamp of its first event. As such we now // need to expand the score window to the end of the cluster, and we do so // by using the Bad widow if it's there, or the NI window, or finally the // Good window. if (cluster.scoreWindows.bad) { updateTraceWindowMax(cluster.scoreWindows.bad, cluster.clusterWindow.max); } else if (cluster.scoreWindows.needsImprovement) { updateTraceWindowMax(cluster.scoreWindows.needsImprovement, cluster.clusterWindow.max); } else { updateTraceWindowMax(cluster.scoreWindows.good, cluster.clusterWindow.max); } } if (weightedScore > sessionMaxScore) { clsWindowID = windowID; sessionMaxScore = weightedScore; } } } export function data(): LayoutShifts { if (handlerState !== HandlerState.FINALIZED) { throw new Error('Layout Shifts Handler is not finalized'); } return { clusters: [...clusters], sessionMaxScore: sessionMaxScore, clsWindowID, prePaintEvents: [...prePaintEvents], layoutInvalidationEvents: [...layoutInvalidationEvents], styleRecalcInvalidationEvents: [], scoreRecords: [...scoreRecords], }; } export function deps(): TraceEventHandlerName[] { return ['Screenshots', 'Meta']; } export function stateForLayoutShiftScore(score: number): ScoreClassification { let state = ScoreClassification.GOOD; if (score >= LayoutShiftsThreshold.NEEDS_IMPROVEMENT) { state = ScoreClassification.OK; } if (score >= LayoutShiftsThreshold.BAD) { state = ScoreClassification.BAD; } return state; } export interface LayoutShiftCluster { clusterWindow: Types.Timing.TraceWindow; clusterCumulativeScore: number; events: Types.TraceEvents.SyntheticLayoutShift[]; // For convenience we split apart the cluster into good, NI, and bad windows. // Since a cluster may remain in the good window, we mark NI and bad as being // possibly null. scoreWindows: { good: Types.Timing.TraceWindow, needsImprovement: Types.Timing.TraceWindow|null, bad: Types.Timing.TraceWindow|null, }; } // Based on https://web.dev/cls/ export const enum LayoutShiftsThreshold { GOOD = 0, NEEDS_IMPROVEMENT = 0.1, BAD = 0.25, }