UNPKG

lighthouse

Version:

Automated auditing, performance metrics, and best practices for the web.

247 lines (217 loc) • 10.2 kB
/** * @license * Copyright 2019 Google LLC * SPDX-License-Identifier: Apache-2.0 */ import {makeComputedArtifact} from '../computed-artifact.js'; import {ProcessedTrace} from '../processed-trace.js'; import * as RectHelpers from '../../lib/rect-helpers.js'; import * as TraceEngine from '../../lib/trace-engine.js'; import {Sentry} from '../../lib/sentry.js'; /** @typedef {{ts: number, isMainFrame: boolean, weightedScore: number, impactedNodes?: LH.Artifacts.TraceImpactedNode[], event: LH.TraceEvent}} LayoutShiftEvent */ const RECENT_INPUT_WINDOW = 500; class CumulativeLayoutShift { /** * Returns all LayoutShift events that had no recent input. * Only a `weightedScore` per event is returned. For non-main-frame events, this is * the only score that matters. For main-frame events, `weighted_score_delta === score`. * @see https://source.chromium.org/chromium/chromium/src/+/main:third_party/blink/renderer/core/layout/layout_shift_tracker.cc;l=492-495;drc=de3b3a8a8839269c6b44403fa38a13a1ed12fed5 * @param {LH.Artifacts.ProcessedTrace} processedTrace * @return {Array<LayoutShiftEvent>} */ static getLayoutShiftEvents(processedTrace) { const layoutShiftEvents = []; // Chromium will set `had_recent_input` if there was recent user input, which // skips shift events from contributing to CLS. This flag is also set when // Lighthouse changes the emulation size. This results in the first few shift // events having `had_recent_input` set, so ignore it for those events. // See https://bugs.chromium.org/p/chromium/issues/detail?id=1094974. let mustRespectHadRecentInput = false; // Even if emulation was applied before navigating, Chrome will issue a viewport // change event after a navigation starts which is treated as an interaction when // deciding the `had_recent_input` flag. Anything within 500ms of this event should // always be counted for CLS regardless of the `had_recent_input` flag. // See https://bugs.chromium.org/p/chromium/issues/detail?id=1302667 let viewportChangeTs = processedTrace.timestamps.timeOrigin; const firstViewportEvent = processedTrace.frameEvents.find(event => event.name === 'viewport'); if (firstViewportEvent) { viewportChangeTs = firstViewportEvent.ts; } for (const event of processedTrace.frameTreeEvents) { if (event.name !== 'LayoutShift' || !event.args.data || event.args.data.is_main_frame === undefined) { continue; } // For all-frames CLS calculation, we rely on `weighted_score_delta` // All layout shift events should have this since M90: https://crbug.com/1173139 if (event.args.data.weighted_score_delta === undefined) { throw new Error('CLS missing weighted_score_delta'); } if (event.args.data.had_recent_input) { const timing = (event.ts - viewportChangeTs) / 1000; if (timing > RECENT_INPUT_WINDOW || mustRespectHadRecentInput) continue; } else { mustRespectHadRecentInput = true; } layoutShiftEvents.push({ ts: event.ts, isMainFrame: event.args.data.is_main_frame, weightedScore: event.args.data.weighted_score_delta, impactedNodes: event.args.data.impacted_nodes, event, }); } return layoutShiftEvents; } /** * Each layout shift event has a 'score' which is the amount added to the CLS as a result of the given shift(s). * We calculate the score per element by taking the 'score' of each layout shift event and * distributing it between all the nodes that were shifted, proportianal to the impact region of * each shifted element. * * @param {LayoutShiftEvent[]} layoutShiftEvents * @return {Map<number, number>} */ static getImpactByNodeId(layoutShiftEvents) { /** @type {Map<number, number>} */ const impactByNodeId = new Map(); for (const event of layoutShiftEvents) { if (!event.impactedNodes) continue; let totalAreaOfImpact = 0; /** @type {Map<number, number>} */ const pixelsMovedPerNode = new Map(); for (const node of event.impactedNodes) { if (!node.node_id || !node.old_rect || !node.new_rect) continue; const oldRect = RectHelpers.traceRectToLHRect(node.old_rect); const newRect = RectHelpers.traceRectToLHRect(node.new_rect); const areaOfImpact = RectHelpers.getRectArea(oldRect) + RectHelpers.getRectArea(newRect) - RectHelpers.getRectOverlapArea(oldRect, newRect); pixelsMovedPerNode.set(node.node_id, areaOfImpact); totalAreaOfImpact += areaOfImpact; } for (const [nodeId, pixelsMoved] of pixelsMovedPerNode.entries()) { let clsContribution = impactByNodeId.get(nodeId) || 0; clsContribution += (pixelsMoved / totalAreaOfImpact) * event.weightedScore; impactByNodeId.set(nodeId, clsContribution); } } return impactByNodeId; } /** * Calculates cumulative layout shifts per cluster (session) of LayoutShift * events -- where a new cluster is created when there's a gap of more than * 1000ms since the last LayoutShift event or the cluster is greater than * 5000ms long -- and returns the max LayoutShift score found. * @param {Array<LayoutShiftEvent>} layoutShiftEvents * @return {number} */ static calculate(layoutShiftEvents) { const gapMicroseconds = 1_000_000; const limitMicroseconds = 5_000_000; let maxScore = 0; let currentClusterScore = 0; let firstTs = Number.NEGATIVE_INFINITY; let prevTs = Number.NEGATIVE_INFINITY; for (const event of layoutShiftEvents) { if (event.ts - firstTs > limitMicroseconds || event.ts - prevTs > gapMicroseconds) { firstTs = event.ts; currentClusterScore = 0; } prevTs = event.ts; currentClusterScore += event.weightedScore; maxScore = Math.max(maxScore, currentClusterScore); } return maxScore; } /** * @param {LayoutShiftEvent[]} allFrameShiftEvents * @param {LayoutShiftEvent[]} mainFrameShiftEvents */ static async computeWithSharedTraceEngine(allFrameShiftEvents, mainFrameShiftEvents) { /** @param {LH.TraceEvent[]} events */ const run = async (events) => { const processor = new TraceEngine.TraceProcessor({ LayoutShifts: TraceEngine.TraceHandlers.LayoutShifts, Screenshots: TraceEngine.TraceHandlers.Screenshots, }); await processor.parse(/** @type {import('@paulirish/trace_engine').Types.Events.Event[]} */ ( events ), {}); if (!processor.data) { throw new Error('null trace engine result'); } return processor.data.LayoutShifts.sessionMaxScore; }; const cumulativeLayoutShift = await run(allFrameShiftEvents.map(e => e.event)); const cumulativeLayoutShiftMainFrame = await run(mainFrameShiftEvents.map(e => e.event)); return {cumulativeLayoutShift, cumulativeLayoutShiftMainFrame}; } /** * @param {LH.Trace} trace * @param {LH.Artifacts.ComputedContext} context * @return {Promise<{cumulativeLayoutShift: number, cumulativeLayoutShiftMainFrame: number, impactByNodeId: Map<number, number>, newEngineResult?: {cumulativeLayoutShift: number, cumulativeLayoutShiftMainFrame: number}, newEngineResultDiffered: boolean}>} */ static async compute_(trace, context) { const processedTrace = await ProcessedTrace.request(trace, context); const allFrameShiftEvents = CumulativeLayoutShift.getLayoutShiftEvents(processedTrace); const impactByNodeId = CumulativeLayoutShift.getImpactByNodeId(allFrameShiftEvents); const mainFrameShiftEvents = allFrameShiftEvents.filter(e => e.isMainFrame); const cumulativeLayoutShift = CumulativeLayoutShift.calculate(allFrameShiftEvents); const cumulativeLayoutShiftMainFrame = CumulativeLayoutShift.calculate(mainFrameShiftEvents); // Run with the new trace engine, and only throw an error if we are running our unit tests. // Otherwise, simply report any differences or errors to Sentry. // TODO: TraceEngine always drops `had_recent_input` events, but Lighthouse is more lenient. // See comment in `getLayoutShiftEvents`. We need to upstream this. let newEngineResult; let newEngineResultDiffered = false; let tryNewTraceEngine = true; if (allFrameShiftEvents.some(e => e.event.args.data?.had_recent_input)) { tryNewTraceEngine = false; } if (tryNewTraceEngine) { try { newEngineResult = await this.computeWithSharedTraceEngine(allFrameShiftEvents, mainFrameShiftEvents); newEngineResultDiffered = newEngineResult.cumulativeLayoutShift !== cumulativeLayoutShift || newEngineResult.cumulativeLayoutShiftMainFrame !== cumulativeLayoutShiftMainFrame; if (newEngineResultDiffered) { newEngineResultDiffered = true; const expected = JSON.stringify({cumulativeLayoutShift, cumulativeLayoutShiftMainFrame}); const got = JSON.stringify(newEngineResult); throw new Error(`new trace engine differed. expected: ${expected}, got: ${got}`); } } catch (err) { // eslint-disable-next-line no-console console.error(err); newEngineResultDiffered = true; const error = new Error('Error when using new trace engine', {cause: err}); // @ts-expect-error Check for running from tests. if (global.expect) { throw error; } else { Sentry.captureException(error, { tags: {computed: 'new-trace-engine'}, level: 'error', extra: { // Not sure if Sentry handles `cause`, so just in case add the info in a second place. errorMsg: err.toString(), }, }); } } } return { cumulativeLayoutShift, cumulativeLayoutShiftMainFrame, impactByNodeId, newEngineResult, newEngineResultDiffered, }; } } const CumulativeLayoutShiftComputed = makeComputedArtifact(CumulativeLayoutShift, null); export {CumulativeLayoutShiftComputed as CumulativeLayoutShift};