UNPKG

chrome-devtools-frontend

Version:
608 lines (558 loc) • 23.8 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 Platform from '../../../core/platform/platform.js'; import type * as Protocol from '../../../generated/protocol.js'; import * as Handlers from '../handlers/handlers.js'; import * as Helpers from '../helpers/helpers.js'; import * as Types from '../types/types.js'; import { InsightCategory, InsightKeys, type InsightModel, type InsightSetContext, type PartialInsightModel, } from './types.js'; export const UIStrings = { /** Title of an insight that provides details about why elements shift/move on the page. The causes for these shifts are referred to as culprits ("reasons"). */ title: 'Layout shift culprits', /** * @description Description of a DevTools insight that identifies the reasons that elements shift on the page. * This is displayed after a user expands the section to see more. No character length limits. */ description: 'Layout shifts occur when elements move absent any user interaction. [Investigate the causes of layout shifts](https://web.dev/articles/optimize-cls), such as elements being added, removed, or their fonts changing as the page loads.', /** *@description Text indicating the worst layout shift cluster. */ worstLayoutShiftCluster: 'Worst layout shift cluster', /** * @description Text indicating the worst layout shift cluster. */ worstCluster: 'Worst cluster', /** * @description Text indicating a layout shift cluster and its start time. * @example {32 ms} PH1 */ layoutShiftCluster: 'Layout shift cluster @ {PH1}', /** *@description Text indicating the biggest reasons for the layout shifts. */ topCulprits: 'Top layout shift culprits', /** * @description Text for a culprit type of Injected iframe. */ injectedIframe: 'Injected iframe', /** * @description Text for a culprit type of Font request. */ fontRequest: 'Font request', /** * @description Text for a culprit type of Animation. */ animation: 'Animation', /** * @description Text for a culprit type of Unsized images. */ unsizedImages: 'Unsized Images', /** * @description Text status when there were no layout shifts detected. */ noLayoutShifts: 'No layout shifts', /** * @description Text status when there no layout shifts culprits/root causes were found. */ noCulprits: 'Could not detect any layout shift culprits', } as const; const str_ = i18n.i18n.registerUIStrings('models/trace/insights/CLSCulprits.ts', UIStrings); export const i18nString = i18n.i18n.getLocalizedString.bind(undefined, str_); export type CLSCulpritsInsightModel = InsightModel<typeof UIStrings, { animationFailures: readonly NoncompositedAnimationFailure[], shifts: Map<Types.Events.SyntheticLayoutShift, LayoutShiftRootCausesData>, clusters: Types.Events.SyntheticLayoutShiftCluster[], worstCluster: Types.Events.SyntheticLayoutShiftCluster | undefined, /** The top 3 shift root causes for each cluster. */ topCulpritsByCluster: Map<Types.Events.SyntheticLayoutShiftCluster, Platform.UIString.LocalizedString[]>, }>; export const enum AnimationFailureReasons { ACCELERATED_ANIMATIONS_DISABLED = 'ACCELERATED_ANIMATIONS_DISABLED', EFFECT_SUPPRESSED_BY_DEVTOOLS = 'EFFECT_SUPPRESSED_BY_DEVTOOLS', INVALID_ANIMATION_OR_EFFECT = 'INVALID_ANIMATION_OR_EFFECT', EFFECT_HAS_UNSUPPORTED_TIMING_PARAMS = 'EFFECT_HAS_UNSUPPORTED_TIMING_PARAMS', EFFECT_HAS_NON_REPLACE_COMPOSITE_MODE = 'EFFECT_HAS_NON_REPLACE_COMPOSITE_MODE', TARGET_HAS_INVALID_COMPOSITING_STATE = 'TARGET_HAS_INVALID_COMPOSITING_STATE', TARGET_HAS_INCOMPATIBLE_ANIMATIONS = 'TARGET_HAS_INCOMPATIBLE_ANIMATIONS', TARGET_HAS_CSS_OFFSET = 'TARGET_HAS_CSS_OFFSET', ANIMATION_AFFECTS_NON_CSS_PROPERTIES = 'ANIMATION_AFFECTS_NON_CSS_PROPERTIES', TRANSFORM_RELATED_PROPERTY_CANNOT_BE_ACCELERATED_ON_TARGET = 'TRANSFORM_RELATED_PROPERTY_CANNOT_BE_ACCELERATED_ON_TARGET', TRANSFROM_BOX_SIZE_DEPENDENT = 'TRANSFROM_BOX_SIZE_DEPENDENT', FILTER_RELATED_PROPERTY_MAY_MOVE_PIXELS = 'FILTER_RELATED_PROPERTY_MAY_MOVE_PIXELS', UNSUPPORTED_CSS_PROPERTY = 'UNSUPPORTED_CSS_PROPERTY', MIXED_KEYFRAME_VALUE_TYPES = 'MIXED_KEYFRAME_VALUE_TYPES', TIMELINE_SOURCE_HAS_INVALID_COMPOSITING_STATE = 'TIMELINE_SOURCE_HAS_INVALID_COMPOSITING_STATE', ANIMATION_HAS_NO_VISIBLE_CHANGE = 'ANIMATION_HAS_NO_VISIBLE_CHANGE', AFFECTS_IMPORTANT_PROPERTY = 'AFFECTS_IMPORTANT_PROPERTY', SVG_TARGET_HAS_INDEPENDENT_TRANSFORM_PROPERTY = 'SVG_TARGET_HAS_INDEPENDENT_TRANSFORM_PROPERTY', } export interface NoncompositedAnimationFailure { /** * Animation name. */ name?: string; /** * Failure reason based on mask number defined in * https://source.chromium.org/search?q=f:compositor_animations.h%20%22enum%20FailureReason%22. */ failureReasons: AnimationFailureReasons[]; /** * Unsupported properties. */ unsupportedProperties?: Types.Events.Animation['args']['data']['unsupportedProperties']; /** * Animation event. */ animation?: Types.Events.SyntheticAnimationPair; } /** * Each failure reason is represented by a bit flag. The bit shift operator '<<' is used to define * which bit corresponds to each failure reason. * https://source.chromium.org/search?q=f:compositor_animations.h%20%22enum%20FailureReason%22 */ const ACTIONABLE_FAILURE_REASONS: Array<{ flag: number, failure: AnimationFailureReasons, }> = [ { flag: 1 << 0, failure: AnimationFailureReasons.ACCELERATED_ANIMATIONS_DISABLED, }, { flag: 1 << 1, failure: AnimationFailureReasons.EFFECT_SUPPRESSED_BY_DEVTOOLS, }, { flag: 1 << 2, failure: AnimationFailureReasons.INVALID_ANIMATION_OR_EFFECT, }, { flag: 1 << 3, failure: AnimationFailureReasons.EFFECT_HAS_UNSUPPORTED_TIMING_PARAMS, }, { flag: 1 << 4, failure: AnimationFailureReasons.EFFECT_HAS_NON_REPLACE_COMPOSITE_MODE, }, { flag: 1 << 5, failure: AnimationFailureReasons.TARGET_HAS_INVALID_COMPOSITING_STATE, }, { flag: 1 << 6, failure: AnimationFailureReasons.TARGET_HAS_INCOMPATIBLE_ANIMATIONS, }, { flag: 1 << 7, failure: AnimationFailureReasons.TARGET_HAS_CSS_OFFSET, }, // The failure 1 << 8 is marked as obsolete in Blink { flag: 1 << 9, failure: AnimationFailureReasons.ANIMATION_AFFECTS_NON_CSS_PROPERTIES, }, { flag: 1 << 10, failure: AnimationFailureReasons.TRANSFORM_RELATED_PROPERTY_CANNOT_BE_ACCELERATED_ON_TARGET, }, { flag: 1 << 11, failure: AnimationFailureReasons.TRANSFROM_BOX_SIZE_DEPENDENT, }, { flag: 1 << 12, failure: AnimationFailureReasons.FILTER_RELATED_PROPERTY_MAY_MOVE_PIXELS, }, { flag: 1 << 13, failure: AnimationFailureReasons.UNSUPPORTED_CSS_PROPERTY, }, // The failure 1 << 14 is marked as obsolete in Blink { flag: 1 << 15, failure: AnimationFailureReasons.MIXED_KEYFRAME_VALUE_TYPES, }, { flag: 1 << 16, failure: AnimationFailureReasons.TIMELINE_SOURCE_HAS_INVALID_COMPOSITING_STATE, }, { flag: 1 << 17, failure: AnimationFailureReasons.ANIMATION_HAS_NO_VISIBLE_CHANGE, }, { flag: 1 << 18, failure: AnimationFailureReasons.AFFECTS_IMPORTANT_PROPERTY, }, { flag: 1 << 19, failure: AnimationFailureReasons.SVG_TARGET_HAS_INDEPENDENT_TRANSFORM_PROPERTY, }, ] as const; // 500ms window. // Use this window to consider events and requests that may have caused a layout shift. const ROOT_CAUSE_WINDOW = Helpers.Timing.secondsToMicro(Types.Timing.Seconds(0.5)); export interface UnsizedImage { backendNodeId: Protocol.DOM.BackendNodeId; paintImageEvent: Types.Events.PaintImage; } export interface LayoutShiftRootCausesData { iframeIds: string[]; fontRequests: Types.Events.SyntheticNetworkRequest[]; nonCompositedAnimations: NoncompositedAnimationFailure[]; unsizedImages: UnsizedImage[]; } /** * Returns if an event happens within the root cause window, before the target event. * ROOT_CAUSE_WINDOW v target event * |------------------------|======================= */ function isInRootCauseWindow(event: Types.Events.Event, targetEvent: Types.Events.Event): boolean { const eventEnd = event.dur ? event.ts + event.dur : event.ts; return eventEnd < targetEvent.ts && eventEnd >= targetEvent.ts - ROOT_CAUSE_WINDOW; } export function getNonCompositedFailure(animationEvent: Types.Events.SyntheticAnimationPair): NoncompositedAnimationFailure[] { const failures: NoncompositedAnimationFailure[] = []; const beginEvent = animationEvent.args.data.beginEvent; const instantEvents = animationEvent.args.data.instantEvents || []; /** * Animation events containing composite information are ASYNC_NESTABLE_INSTANT ('n'). * An animation may also contain multiple 'n' events, so we look through those with useful non-composited data. */ for (const event of instantEvents) { const failureMask = event.args.data.compositeFailed; const unsupportedProperties = event.args.data.unsupportedProperties; if (!failureMask) { continue; } const failureReasons = ACTIONABLE_FAILURE_REASONS.filter(reason => failureMask & reason.flag).map(reason => reason.failure); const failure: NoncompositedAnimationFailure = { name: beginEvent.args.data.displayName, failureReasons, unsupportedProperties, animation: animationEvent, }; failures.push(failure); } return failures; } function getNonCompositedFailureRootCauses( animationEvents: Types.Events.SyntheticAnimationPair[], prePaintEvents: Types.Events.PrePaint[], shiftsByPrePaint: Map<Types.Events.PrePaint, Types.Events.SyntheticLayoutShift[]>, rootCausesByShift: Map<Types.Events.SyntheticLayoutShift, LayoutShiftRootCausesData>, ): NoncompositedAnimationFailure[] { const allAnimationFailures: NoncompositedAnimationFailure[] = []; for (const animation of animationEvents) { /** * Animation events containing composite information are ASYNC_NESTABLE_INSTANT ('n'). * An animation may also contain multiple 'n' events, so we look through those with useful non-composited data. */ const failures = getNonCompositedFailure(animation); if (!failures) { continue; } allAnimationFailures.push(...failures); const nextPrePaint = getNextEvent(prePaintEvents, animation) as Types.Events.PrePaint | null; // If no following prePaint, this is not a root cause. if (!nextPrePaint) { continue; } // If the animation event is outside the ROOT_CAUSE_WINDOW, it could not be a root cause. if (!isInRootCauseWindow(animation, nextPrePaint)) { continue; } const shifts = shiftsByPrePaint.get(nextPrePaint); // if no layout shift(s), this is not a root cause. if (!shifts) { continue; } for (const shift of shifts) { const rootCausesForShift = rootCausesByShift.get(shift); if (!rootCausesForShift) { throw new Error('Unaccounted shift'); } rootCausesForShift.nonCompositedAnimations.push(...failures); } } return allAnimationFailures; } /** * Given an array of layout shift and PrePaint events, returns a mapping from * PrePaint events to layout shifts dispatched within it. */ function getShiftsByPrePaintEvents( layoutShifts: Types.Events.SyntheticLayoutShift[], prePaintEvents: Types.Events.PrePaint[], ): Map<Types.Events.PrePaint, Types.Events.SyntheticLayoutShift[]> { // Maps from PrePaint events to LayoutShifts that occurred in each one. const shiftsByPrePaint = new Map<Types.Events.PrePaint, Types.Events.SyntheticLayoutShift[]>(); // Associate all shifts to their corresponding PrePaint. for (const prePaintEvent of prePaintEvents) { const firstShiftIndex = Platform.ArrayUtilities.nearestIndexFromBeginning(layoutShifts, shift => shift.ts >= prePaintEvent.ts); if (firstShiftIndex === null) { // No layout shifts registered after this PrePaint start. Continue. continue; } for (let i = firstShiftIndex; i < layoutShifts.length; i++) { const shift = layoutShifts[i]; if (shift.ts >= prePaintEvent.ts && shift.ts <= prePaintEvent.ts + prePaintEvent.dur) { const shiftsInPrePaint = Platform.MapUtilities.getWithDefault(shiftsByPrePaint, prePaintEvent, () => []); shiftsInPrePaint.push(shift); } if (shift.ts > prePaintEvent.ts + prePaintEvent.dur) { // Reached all layoutShifts of this PrePaint. Break out to continue with the next prePaint event. break; } } } return shiftsByPrePaint; } /** * Given a source event list, this returns the first event of that list that directly follows the target event. */ function getNextEvent(sourceEvents: Types.Events.Event[], targetEvent: Types.Events.Event): Types.Events.Event| undefined { const index = Platform.ArrayUtilities.nearestIndexFromBeginning( sourceEvents, source => source.ts > targetEvent.ts + (targetEvent.dur || 0)); // No PrePaint event registered after this event if (index === null) { return undefined; } return sourceEvents[index]; } /** * An Iframe is considered a root cause if the iframe event occurs before a prePaint event * and within this prePaint event a layout shift(s) occurs. */ function getIframeRootCauses( iframeCreatedEvents: readonly Types.Events.RenderFrameImplCreateChildFrame[], prePaintEvents: Types.Events.PrePaint[], shiftsByPrePaint: Map<Types.Events.PrePaint, Types.Events.SyntheticLayoutShift[]>, rootCausesByShift: Map<Types.Events.SyntheticLayoutShift, LayoutShiftRootCausesData>, domLoadingEvents: readonly Types.Events.DomLoading[]): Map<Types.Events.SyntheticLayoutShift, LayoutShiftRootCausesData> { for (const iframeEvent of iframeCreatedEvents) { const nextPrePaint = getNextEvent(prePaintEvents, iframeEvent) as Types.Events.PrePaint | null; // If no following prePaint, this is not a root cause. if (!nextPrePaint) { continue; } const shifts = shiftsByPrePaint.get(nextPrePaint); // if no layout shift(s), this is not a root cause. if (!shifts) { continue; } for (const shift of shifts) { const rootCausesForShift = rootCausesByShift.get(shift); if (!rootCausesForShift) { throw new Error('Unaccounted shift'); } // Look for the first dom event that occurs within the bounds of the iframe event. // This contains the frame id. const domEvent = domLoadingEvents.find(e => { const maxIframe = Types.Timing.Micro(iframeEvent.ts + (iframeEvent.dur ?? 0)); return e.ts >= iframeEvent.ts && e.ts <= maxIframe; }); if (domEvent?.args.frame) { rootCausesForShift.iframeIds.push(domEvent.args.frame); } } } return rootCausesByShift; } /** * An unsized image is considered a root cause if its PaintImage can be correlated to a * layout shift. We can correlate PaintImages with unsized images by their matching nodeIds. * X <- layout shift * |----------------| * ^ PrePaint event |-----| * ^ PaintImage */ function getUnsizedImageRootCauses( unsizedImageEvents: readonly Types.Events.LayoutImageUnsized[], paintImageEvents: Types.Events.PaintImage[], shiftsByPrePaint: Map<Types.Events.PrePaint, Types.Events.SyntheticLayoutShift[]>, rootCausesByShift: Map<Types.Events.SyntheticLayoutShift, LayoutShiftRootCausesData>): Map<Types.Events.SyntheticLayoutShift, LayoutShiftRootCausesData> { shiftsByPrePaint.forEach((shifts, prePaint) => { const paintImage = getNextEvent(paintImageEvents, prePaint) as Types.Events.PaintImage | null; if (!paintImage) { return; } // The unsized image corresponds to this PaintImage. const matchingNode = unsizedImageEvents.find(unsizedImage => unsizedImage.args.data.nodeId === paintImage.args.data.nodeId); if (!matchingNode) { return; } // The unsized image is a potential root cause of all the shifts of this prePaint. for (const shift of shifts) { const rootCausesForShift = rootCausesByShift.get(shift); if (!rootCausesForShift) { throw new Error('Unaccounted shift'); } rootCausesForShift.unsizedImages.push({ backendNodeId: matchingNode.args.data.nodeId, paintImageEvent: paintImage, }); } }); return rootCausesByShift; } export function isCLSCulprits(insight: InsightModel): insight is CLSCulpritsInsightModel { return insight.insightKey === InsightKeys.CLS_CULPRITS; } /** * A font request is considered a root cause if the request occurs before a prePaint event * and within this prePaint event a layout shift(s) occurs. Additionally, this font request should * happen within the ROOT_CAUSE_WINDOW of the prePaint event. */ function getFontRootCauses( networkRequests: Types.Events.SyntheticNetworkRequest[], prePaintEvents: Types.Events.PrePaint[], shiftsByPrePaint: Map<Types.Events.PrePaint, Types.Events.SyntheticLayoutShift[]>, rootCausesByShift: Map<Types.Events.SyntheticLayoutShift, LayoutShiftRootCausesData>): Map<Types.Events.SyntheticLayoutShift, LayoutShiftRootCausesData> { const fontRequests = networkRequests.filter(req => req.args.data.resourceType === 'Font' && req.args.data.mimeType.startsWith('font')); for (const req of fontRequests) { const nextPrePaint = getNextEvent(prePaintEvents, req) as Types.Events.PrePaint | null; if (!nextPrePaint) { continue; } // If the req is outside the ROOT_CAUSE_WINDOW, it could not be a root cause. if (!isInRootCauseWindow(req, nextPrePaint)) { continue; } // Get the shifts that belong to this prepaint const shifts = shiftsByPrePaint.get(nextPrePaint); // if no layout shift(s) in this prePaint, the request is not a root cause. if (!shifts) { continue; } // Include the root cause to the shifts in this prePaint. for (const shift of shifts) { const rootCausesForShift = rootCausesByShift.get(shift); if (!rootCausesForShift) { throw new Error('Unaccounted shift'); } rootCausesForShift.fontRequests.push(req); } } return rootCausesByShift; } /** * Returns the top 3 shift root causes based on the given cluster. */ function getTopCulprits( cluster: Types.Events.SyntheticLayoutShiftCluster, culpritsByShift: Map<Types.Events.SyntheticLayoutShift, LayoutShiftRootCausesData>): Platform.UIString.LocalizedString[] { const MAX_TOP_CULPRITS = 3; const causes: Platform.UIString.LocalizedString[] = []; const shifts = cluster.events; for (const shift of shifts) { const culprits = culpritsByShift.get(shift); if (!culprits) { continue; } const fontReq = culprits.fontRequests; const iframes = culprits.iframeIds; const animations = culprits.nonCompositedAnimations; const unsizedImages = culprits.unsizedImages; for (let i = 0; i < fontReq.length && causes.length < MAX_TOP_CULPRITS; i++) { causes.push(i18nString(UIStrings.fontRequest)); } for (let i = 0; i < iframes.length && causes.length < MAX_TOP_CULPRITS; i++) { causes.push(i18nString(UIStrings.injectedIframe)); } for (let i = 0; i < animations.length && causes.length < MAX_TOP_CULPRITS; i++) { causes.push(i18nString(UIStrings.animation)); } for (let i = 0; i < unsizedImages.length && causes.length < MAX_TOP_CULPRITS; i++) { causes.push(i18nString(UIStrings.unsizedImages)); } if (causes.length >= MAX_TOP_CULPRITS) { break; } } return causes.slice(0, MAX_TOP_CULPRITS); } function finalize(partialModel: PartialInsightModel<CLSCulpritsInsightModel>): CLSCulpritsInsightModel { let state: CLSCulpritsInsightModel['state'] = 'pass'; if (partialModel.worstCluster) { const classification = Handlers.ModelHandlers.LayoutShifts.scoreClassificationForLayoutShift( partialModel.worstCluster.clusterCumulativeScore); if (classification === Handlers.ModelHandlers.PageLoadMetrics.ScoreClassification.GOOD) { state = 'informative'; } else { state = 'fail'; } } return { insightKey: InsightKeys.CLS_CULPRITS, strings: UIStrings, title: i18nString(UIStrings.title), description: i18nString(UIStrings.description), category: InsightCategory.CLS, state, ...partialModel, }; } export function generateInsight( parsedTrace: Handlers.Types.ParsedTrace, context: InsightSetContext): CLSCulpritsInsightModel { const isWithinContext = (event: Types.Events.Event): boolean => Helpers.Timing.eventIsInBounds(event, context.bounds); const compositeAnimationEvents = parsedTrace.Animations.animations.filter(isWithinContext); const iframeEvents = parsedTrace.LayoutShifts.renderFrameImplCreateChildFrameEvents.filter(isWithinContext); const networkRequests = parsedTrace.NetworkRequests.byTime.filter(isWithinContext); const domLoadingEvents = parsedTrace.LayoutShifts.domLoadingEvents.filter(isWithinContext); const unsizedImageEvents = parsedTrace.LayoutShifts.layoutImageUnsizedEvents.filter(isWithinContext); const clusterKey = context.navigation ? context.navigationId : Types.Events.NO_NAVIGATION; const clusters = parsedTrace.LayoutShifts.clustersByNavigationId.get(clusterKey) ?? []; const clustersByScore = clusters.toSorted((a, b) => b.clusterCumulativeScore - a.clusterCumulativeScore); const worstCluster = clustersByScore.at(0); const layoutShifts = clusters.flatMap(cluster => cluster.events); const prePaintEvents = parsedTrace.LayoutShifts.prePaintEvents.filter(isWithinContext); const paintImageEvents = parsedTrace.LayoutShifts.paintImageEvents.filter(isWithinContext); // Get root causes. const rootCausesByShift = new Map<Types.Events.SyntheticLayoutShift, LayoutShiftRootCausesData>(); const shiftsByPrePaint = getShiftsByPrePaintEvents(layoutShifts, prePaintEvents); for (const shift of layoutShifts) { rootCausesByShift.set(shift, {iframeIds: [], fontRequests: [], nonCompositedAnimations: [], unsizedImages: []}); } // Populate root causes for rootCausesByShift. getIframeRootCauses(iframeEvents, prePaintEvents, shiftsByPrePaint, rootCausesByShift, domLoadingEvents); getFontRootCauses(networkRequests, prePaintEvents, shiftsByPrePaint, rootCausesByShift); getUnsizedImageRootCauses(unsizedImageEvents, paintImageEvents, shiftsByPrePaint, rootCausesByShift); const animationFailures = getNonCompositedFailureRootCauses(compositeAnimationEvents, prePaintEvents, shiftsByPrePaint, rootCausesByShift); const relatedEvents: Types.Events.Event[] = [...layoutShifts]; if (worstCluster) { relatedEvents.push(worstCluster); } const topCulpritsByCluster = new Map<Types.Events.SyntheticLayoutShiftCluster, Platform.UIString.LocalizedString[]>(); for (const cluster of clusters) { topCulpritsByCluster.set(cluster, getTopCulprits(cluster, rootCausesByShift)); } return finalize({ relatedEvents, animationFailures, shifts: rootCausesByShift, clusters, worstCluster, topCulpritsByCluster, }); }