chrome-devtools-frontend
Version:
Chrome DevTools UI
567 lines (506 loc) • 23.9 kB
text/typescript
// 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 Platform from '../../../core/platform/platform.js';
import type * as Protocol from '../../../generated/protocol.js';
import * as Helpers from '../helpers/helpers.js';
import * as Types from '../types/types.js';
import {data as metaHandlerData} from './MetaHandler.js';
import {ScoreClassification} from './PageLoadMetricsHandler.js';
import {data as screenshotsHandlerData} from './ScreenshotsHandler.js';
import type {HandlerName} from './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: readonly Types.Events.SyntheticLayoutShiftCluster[];
clustersByNavigationId: Map<Types.Events.NavigationId, Types.Events.SyntheticLayoutShiftCluster[]>;
sessionMaxScore: number;
// The session window which contains the SessionMaxScore
clsWindowID: number;
// We use these to calculate root causes for a given LayoutShift
// TODO(crbug/41484172): should be readonly
prePaintEvents: Types.Events.PrePaint[];
paintImageEvents: Types.Events.PaintImage[];
layoutInvalidationEvents: readonly Types.Events.LayoutInvalidationTracking[];
scheduleStyleInvalidationEvents: readonly Types.Events.ScheduleStyleInvalidationTracking[];
styleRecalcInvalidationEvents: readonly Types.Events.StyleRecalcInvalidationTracking[];
renderFrameImplCreateChildFrameEvents: readonly Types.Events.RenderFrameImplCreateChildFrame[];
domLoadingEvents: readonly Types.Events.DomLoading[];
layoutImageUnsizedEvents: readonly Types.Events.LayoutImageUnsized[];
remoteFonts: readonly RemoteFont[];
scoreRecords: readonly ScoreRecord[];
// TODO(crbug/41484172): should be readonly
backendNodeIds: Protocol.DOM.BackendNodeId[];
}
interface RemoteFont {
display: string;
url?: string;
name?: string;
beginRemoteFontLoadEvent: Types.Events.BeginRemoteFontLoad;
}
// This represents the maximum #time we will allow a cluster to go before we
// reset it.
export const MAX_CLUSTER_DURATION = Helpers.Timing.milliToMicro(Types.Timing.Milli(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.milliToMicro(Types.Timing.Milli(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.Events.LayoutShift[] = [];
// These events denote potential node resizings. We store them to link captured
// layout shifts to the resizing of unsized elements.
const layoutInvalidationEvents: Types.Events.LayoutInvalidationTracking[] = [];
const scheduleStyleInvalidationEvents: Types.Events.ScheduleStyleInvalidationTracking[] = [];
const styleRecalcInvalidationEvents: Types.Events.StyleRecalcInvalidationTracking[] = [];
const renderFrameImplCreateChildFrameEvents: Types.Events.RenderFrameImplCreateChildFrame[] = [];
const domLoadingEvents: Types.Events.DomLoading[] = [];
const layoutImageUnsizedEvents: Types.Events.LayoutImageUnsized[] = [];
const remoteFonts: RemoteFont[] = [];
const backendNodeIds = new Set<Protocol.DOM.BackendNodeId>();
// 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.Events.PrePaint[] = [];
const paintImageEvents: Types.Events.PaintImage[] = [];
let sessionMaxScore = 0;
let clsWindowID = -1;
const clusters: Types.Events.SyntheticLayoutShiftCluster[] = [];
const clustersByNavigationId = new Map<Types.Events.NavigationId, Types.Events.SyntheticLayoutShiftCluster[]>();
// Represents a point in time in which a LS score change
// was recorded.
interface 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[] = [];
export function reset(): void {
layoutShiftEvents.length = 0;
layoutInvalidationEvents.length = 0;
scheduleStyleInvalidationEvents.length = 0;
styleRecalcInvalidationEvents.length = 0;
prePaintEvents.length = 0;
paintImageEvents.length = 0;
renderFrameImplCreateChildFrameEvents.length = 0;
layoutImageUnsizedEvents.length = 0;
domLoadingEvents.length = 0;
remoteFonts.length = 0;
backendNodeIds.clear();
clusters.length = 0;
sessionMaxScore = 0;
scoreRecords.length = 0;
clsWindowID = -1;
clustersByNavigationId.clear();
}
export function handleEvent(event: Types.Events.Event): void {
if (Types.Events.isLayoutShift(event) && !event.args.data?.had_recent_input) {
layoutShiftEvents.push(event);
return;
}
if (Types.Events.isLayoutInvalidationTracking(event)) {
layoutInvalidationEvents.push(event);
return;
}
if (Types.Events.isScheduleStyleInvalidationTracking(event)) {
scheduleStyleInvalidationEvents.push(event);
}
if (Types.Events.isStyleRecalcInvalidationTracking(event)) {
styleRecalcInvalidationEvents.push(event);
}
if (Types.Events.isPrePaint(event)) {
prePaintEvents.push(event);
return;
}
if (Types.Events.isRenderFrameImplCreateChildFrame(event)) {
renderFrameImplCreateChildFrameEvents.push(event);
}
if (Types.Events.isDomLoading(event)) {
domLoadingEvents.push(event);
}
if (Types.Events.isLayoutImageUnsized(event)) {
layoutImageUnsizedEvents.push(event);
}
if (Types.Events.isBeginRemoteFontLoad(event)) {
remoteFonts.push({
display: event.args.display,
url: event.args.url,
beginRemoteFontLoadEvent: event,
});
}
if (Types.Events.isRemoteFontLoaded(event)) {
for (const remoteFont of remoteFonts) {
if (remoteFont.url === event.args.url) {
remoteFont.name = event.args.name;
}
}
}
if (Types.Events.isPaintImage(event)) {
paintImageEvents.push(event);
}
}
function traceWindowFromTime(time: Types.Timing.Micro): Types.Timing.TraceWindowMicro {
return {
min: time,
max: time,
range: Types.Timing.Micro(0),
};
}
function updateTraceWindowMax(traceWindow: Types.Timing.TraceWindowMicro, newMax: Types.Timing.Micro): void {
traceWindow.max = newMax;
traceWindow.range = Types.Timing.Micro(traceWindow.max - traceWindow.min);
}
function findScreenshots(timestamp: Types.Timing.Micro): Types.Events.LayoutShiftParsedData['screenshots'] {
const data = screenshotsHandlerData();
if (data.screenshots) {
const before = Helpers.Trace.findPreviousEventBeforeTimestamp(data.screenshots, timestamp);
const after = before ? data.screenshots[data.screenshots.indexOf(before) + 1] : null;
return {before, after};
}
if (data.legacySyntheticScreenshots) {
const before = Helpers.Trace.findPreviousEventBeforeTimestamp(data.legacySyntheticScreenshots, timestamp);
const after = before ? data.legacySyntheticScreenshots[data.legacySyntheticScreenshots.indexOf(before) + 1] : null;
return {before, after};
}
// No screenshots
return {before: null, after: null};
}
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});
}
}
/**
* Collects backend node ids coming from LayoutShift and LayoutInvalidation
* events.
*/
function collectNodes(): void {
backendNodeIds.clear();
// Collect the node ids present in the shifts.
for (const layoutShift of layoutShiftEvents) {
if (!layoutShift.args.data?.impacted_nodes) {
continue;
}
for (const node of layoutShift.args.data.impacted_nodes) {
backendNodeIds.add(node.node_id);
}
}
// Collect the node ids present in LayoutInvalidation & scheduleStyleInvalidation events.
for (const layoutInvalidation of layoutInvalidationEvents) {
if (!layoutInvalidation.args.data?.nodeId) {
continue;
}
backendNodeIds.add(layoutInvalidation.args.data.nodeId);
}
for (const scheduleStyleInvalidation of scheduleStyleInvalidationEvents) {
if (!scheduleStyleInvalidation.args.data?.nodeId) {
continue;
}
backendNodeIds.add(scheduleStyleInvalidation.args.data.nodeId);
}
}
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);
renderFrameImplCreateChildFrameEvents.sort((a, b) => a.ts - b.ts);
domLoadingEvents.sort((a, b) => a.ts - b.ts);
layoutImageUnsizedEvents.sort((a, b) => a.ts - b.ts);
remoteFonts.sort((a, b) => a.beginRemoteFontLoadEvent.ts - b.beginRemoteFontLoadEvent.ts);
paintImageEvents.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();
collectNodes();
}
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.Micro(previousClusterEndTime));
}
// If this cluster happened after a navigation, set the navigationId to
// the current navigation. This lets us easily group clusters by
// navigation.
const navigationId = currentShiftNavigation === null ?
Types.Events.NO_NAVIGATION :
navigations[currentShiftNavigation].args.data?.navigationId;
// TODO: `navigationId` is `string | undefined`, but the undefined portion
// comes from `data.navigationId`. I don't think that is possible for this
// event type. Can we make this typing stronger? In the meantime, we allow
// `navigationId` to include undefined values.
clusters.push({
name: 'SyntheticLayoutShiftCluster',
events: [],
clusterWindow: traceWindowFromTime(clusterStartTime),
clusterCumulativeScore: 0,
scoreWindows: {
good: traceWindowFromTime(clusterStartTime),
},
navigationId,
// Set default Event so that this event is treated accordingly for the track appender.
ts: event.ts,
pid: event.pid,
tid: event.tid,
ph: Types.Events.Phase.COMPLETE,
cat: '',
dur: Types.Timing.Micro(-1), // This `cluster.dur` is updated below.
});
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.Micro(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 =
Helpers.SyntheticEvents.SyntheticEventsManager.registerSyntheticEvent<Types.Events.SyntheticLayoutShift>({
rawSourceEvent: event,
...event,
name: Types.Events.Name.SYNTHETIC_LAYOUT_SHIFT,
args: {
frame: event.args.frame,
data: {
...event.args.data,
rawEvent: event,
navigationId: currentCluster.navigationId ?? undefined,
},
},
parsedData: {
timeFromNavigation,
screenshots: findScreenshots(event.ts),
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.Micro(clusterEnd));
}
let largestScore = 0;
let worstShiftEvent: Types.Events.Event|null = null;
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.Micro(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.Micro(ts - 1));
} else {
updateTraceWindowMax(cluster.scoreWindows.good, Types.Timing.Micro(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);
}
// Find the worst layout shift of the cluster.
const score = shift.args.data?.weighted_score_delta;
if (score !== undefined && score > largestScore) {
largestScore = score;
worstShiftEvent = shift;
}
}
// Update the cluster's worst layout shift.
if (worstShiftEvent) {
cluster.worstShiftEvent = worstShiftEvent;
}
// layout shifts are already sorted by time ascending.
// Capture the time range of the cluster.
cluster.ts = cluster.events[0].ts;
const lastShiftTimings = Helpers.Timing.eventTimingsMicroSeconds(cluster.events[cluster.events.length - 1]);
// Add MAX_SHIFT_TIME_DELTA, the section gap after the last layout shift. This marks the end of the cluster.
cluster.dur = Types.Timing.Micro((lastShiftTimings.endTime - cluster.events[0].ts) + MAX_SHIFT_TIME_DELTA);
if (weightedScore > sessionMaxScore) {
clsWindowID = windowID;
sessionMaxScore = weightedScore;
}
if (cluster.navigationId) {
const clustersForId = Platform.MapUtilities.getWithDefault(clustersByNavigationId, cluster.navigationId, () => {
return [];
});
clustersForId.push(cluster);
}
}
}
export function data(): LayoutShifts {
return {
clusters,
sessionMaxScore,
clsWindowID,
prePaintEvents,
layoutInvalidationEvents,
scheduleStyleInvalidationEvents,
styleRecalcInvalidationEvents: [],
renderFrameImplCreateChildFrameEvents,
domLoadingEvents,
layoutImageUnsizedEvents,
remoteFonts,
scoreRecords,
// TODO(crbug/41484172): change the type so no need to clone
backendNodeIds: [...backendNodeIds],
clustersByNavigationId: new Map(clustersByNavigationId),
paintImageEvents,
};
}
export function deps(): HandlerName[] {
return ['Screenshots', 'Meta'];
}
export function scoreClassificationForLayoutShift(score: number): ScoreClassification {
let state = ScoreClassification.GOOD;
if (score >= LayoutShiftsThreshold.NEEDS_IMPROVEMENT) {
state = ScoreClassification.OK;
}
if (score >= LayoutShiftsThreshold.BAD) {
state = ScoreClassification.BAD;
}
return state;
}
// Based on https://web.dev/cls/
export const enum LayoutShiftsThreshold {
GOOD = 0,
NEEDS_IMPROVEMENT = 0.1,
BAD = 0.25,
}