UNPKG

chrome-devtools-frontend

Version:
666 lines (587 loc) • 27.8 kB
// Copyright 2023 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 type {ParsedTrace} from '../handlers/types.js'; import * as Helpers from '../helpers/helpers.js'; import * as Types from '../types/types.js'; import type {RootCauseProtocolInterface} from './RootCauses.js'; export interface CSSDimensions { width?: string; height?: string; aspectRatio?: string; } export interface UnsizedMedia { node: Protocol.DOM.Node; authoredDimensions?: CSSDimensions; computedDimensions: CSSDimensions; } export interface InjectedIframe { iframe: Protocol.DOM.Node; } export interface RootCauseRequest { request: Types.Events.SyntheticNetworkRequest; initiator?: Protocol.Network.Initiator; } export interface FontChange extends RootCauseRequest { fontFace: Protocol.CSS.FontFace; } export interface RenderBlockingRequest extends RootCauseRequest {} export interface LayoutShiftRootCausesData { unsizedMedia: UnsizedMedia[]; iframes: InjectedIframe[]; fontChanges: FontChange[]; renderBlockingRequests: RenderBlockingRequest[]; scriptStackTrace: Types.Events.CallFrame[]; } const fontRequestsByPrePaint = new Map<Types.Events.PrePaint, FontChange[]|null>(); const renderBlocksByPrePaint = new Map<Types.Events.PrePaint, RenderBlockingRequest[]|null>(); function setDefaultValue( map: Map<Types.Events.SyntheticLayoutShift, LayoutShiftRootCausesData>, shift: Types.Events.SyntheticLayoutShift): void { Platform.MapUtilities.getWithDefault(map, shift, () => { return { unsizedMedia: [], iframes: [], fontChanges: [], renderBlockingRequests: [], scriptStackTrace: [], }; }); } function networkRequestIsRenderBlockingInFrame(event: Types.Events.SyntheticNetworkRequest, frameId: string): boolean { return event.args.data.frame === frameId && Helpers.Network.isSyntheticNetworkRequestEventRenderBlocking(event); } interface Options { /** Checking iframe root causes can be an expensive operation, so it is disabled by default. */ enableIframeRootCauses?: boolean; } export class LayoutShiftRootCauses { #protocolInterface: RootCauseProtocolInterface; #rootCauseCacheMap = new Map<Types.Events.SyntheticLayoutShift, LayoutShiftRootCausesData>(); #nodeDetailsCache = new Map<Protocol.DOM.NodeId, Protocol.DOM.Node|null>(); #iframeRootCausesEnabled: boolean; constructor(protocolInterface: RootCauseProtocolInterface, options?: Options) { this.#protocolInterface = protocolInterface; this.#iframeRootCausesEnabled = options?.enableIframeRootCauses ?? false; } /** * Calculates the potential root causes for a given layout shift event. Once * calculated, this data is cached. * Note: because you need all layout shift data at once to calculate these * correctly, this function will parse the root causes for _all_ layout shift * events the first time that it's called. That then populates the cache for * each shift, so any subsequent calls are just a constant lookup. */ async rootCausesForEvent(modelData: ParsedTrace, event: Types.Events.SyntheticLayoutShift): Promise<Readonly<LayoutShiftRootCausesData>|null> { const cachedResult = this.#rootCauseCacheMap.get(event); if (cachedResult) { return cachedResult; } const allLayoutShifts = modelData.LayoutShifts.clusters.flatMap(cluster => cluster.events); // Make sure a value in the cache is set even for shifts that don't have a root cause, // so that we don't have to recompute when no root causes are found. In case a cause // for a shift is found, the default value is replaced. allLayoutShifts.forEach(shift => setDefaultValue(this.#rootCauseCacheMap, shift)); // Populate the cache await this.blameShifts( allLayoutShifts, modelData, ); const resultForEvent = this.#rootCauseCacheMap.get(event); if (!resultForEvent) { // No root causes available for this layout shift. return null; } return resultForEvent; } /** * Determines potential root causes for shifts */ async blameShifts( layoutShifts: Types.Events.SyntheticLayoutShift[], modelData: ParsedTrace, ): Promise<void> { await this.linkShiftsToLayoutInvalidations(layoutShifts, modelData); this.linkShiftsToLayoutEvents(layoutShifts, modelData); } /** * "LayoutInvalidations" are a set of trace events dispatched in Blink under the name * "layoutInvalidationTracking", which track invalidations on the "Layout"stage of the * rendering pipeline. This function utilizes this event to flag potential root causes * to layout shifts. */ async linkShiftsToLayoutInvalidations(layoutShifts: Types.Events.SyntheticLayoutShift[], modelData: ParsedTrace): Promise<void> { const {prePaintEvents, layoutInvalidationEvents, scheduleStyleInvalidationEvents, backendNodeIds} = modelData.LayoutShifts; // For the purposes of determining root causes of layout shifts, we // consider scheduleStyleInvalidationTracking and // LayoutInvalidationTracking events as events that could have been the // cause of the layout shift. const eventsForLayoutInvalidation: Array<Types.Events.LayoutInvalidationTracking|Types.Events.ScheduleStyleInvalidationTracking> = [...layoutInvalidationEvents, ...scheduleStyleInvalidationEvents]; const nodes = await this.#protocolInterface.pushNodesByBackendIdsToFrontend(backendNodeIds); const nodeIdsByBackendIdMap = new Map<Protocol.DOM.BackendNodeId, Protocol.DOM.NodeId>(); for (let i = 0; i < backendNodeIds.length; i++) { nodeIdsByBackendIdMap.set(backendNodeIds[i], nodes[i]); } // Maps from PrePaint events to LayoutShifts that occurred in each one. const shiftsByPrePaint = getShiftsByPrePaintEvents(layoutShifts, prePaintEvents); for (const layoutInvalidation of eventsForLayoutInvalidation) { // Get the first PrePaint event that happened after the current LayoutInvalidation event. const nextPrePaintIndex = Platform.ArrayUtilities.nearestIndexFromBeginning( prePaintEvents, prePaint => prePaint.ts > layoutInvalidation.ts); if (nextPrePaintIndex === null) { // No PrePaint event registered after this LayoutInvalidation. Continue. continue; } const nextPrePaint = prePaintEvents[nextPrePaintIndex]; const subsequentShifts = shiftsByPrePaint.get(nextPrePaint); if (!subsequentShifts) { // The PrePaint after the current LayoutInvalidation doesn't contain shifts. continue; } const fontChangeRootCause = this.getFontChangeRootCause(layoutInvalidation, nextPrePaint, modelData); const renderBlockRootCause = this.getRenderBlockRootCause(layoutInvalidation, nextPrePaint, modelData); const layoutInvalidationNodeId = nodeIdsByBackendIdMap.get(layoutInvalidation.args.data.nodeId); let unsizedMediaRootCause: UnsizedMedia|null = null; let iframeRootCause: InjectedIframe|null = null; if (layoutInvalidationNodeId !== undefined && Types.Events.isLayoutInvalidationTracking(layoutInvalidation)) { unsizedMediaRootCause = await this.getUnsizedMediaRootCause(layoutInvalidation, layoutInvalidationNodeId); iframeRootCause = await this.getIframeRootCause(layoutInvalidation, layoutInvalidationNodeId); } if (!unsizedMediaRootCause && !iframeRootCause && !fontChangeRootCause && !renderBlockRootCause) { continue; } // Add found potential root causes to all the shifts in this PrePaint and populate the cache. for (const shift of subsequentShifts) { const rootCausesForShift = Platform.MapUtilities.getWithDefault(this.#rootCauseCacheMap, shift, () => { return { unsizedMedia: [], iframes: [], fontChanges: [], renderBlockingRequests: [], scriptStackTrace: [], }; }); if (unsizedMediaRootCause && !rootCausesForShift.unsizedMedia.some(media => media.node.nodeId === unsizedMediaRootCause?.node.nodeId) && shift.args.frame === layoutInvalidation.args.data.frame) { rootCausesForShift.unsizedMedia.push(unsizedMediaRootCause); } if (iframeRootCause && !rootCausesForShift.iframes.some( injectedIframe => injectedIframe.iframe.nodeId === iframeRootCause?.iframe.nodeId)) { rootCausesForShift.iframes.push(iframeRootCause); } if (fontChangeRootCause) { // Unlike other root causes, we calculate fonts causing a shift only once, // which means we assign the built array instead of appending new objects // to it. rootCausesForShift.fontChanges = fontChangeRootCause; } if (renderBlockRootCause) { rootCausesForShift.renderBlockingRequests = renderBlockRootCause; } } } } /** * For every shift looks up the initiator of its corresponding Layout event. This initiator * is assigned by the RendererHandler and contains the stack trace of the point in a script * that caused a style recalculation or a relayout. This stack trace is added to the shift's * potential root causes. * Note that a Layout cannot always be linked to a script, in that case, we cannot add a * "script causing reflow" as a potential root cause to the corresponding shift. */ linkShiftsToLayoutEvents(layoutShifts: Types.Events.SyntheticLayoutShift[], modelData: ParsedTrace): void { const {prePaintEvents} = modelData.LayoutShifts; // Maps from PrePaint events to LayoutShifts that occurred in each one. const shiftsByPrePaint = getShiftsByPrePaintEvents(layoutShifts, prePaintEvents); const eventTriggersLayout = ({name}: {name: string}): boolean => { const knownName = name as Types.Events.Name; return knownName === Types.Events.Name.LAYOUT; }; const layoutEvents = modelData.Renderer.allTraceEntries.filter(eventTriggersLayout); for (const layout of layoutEvents) { // Get the first PrePaint event that happened after the current layout event. const nextPrePaintIndex = Platform.ArrayUtilities.nearestIndexFromBeginning( prePaintEvents, prePaint => prePaint.ts > layout.ts + (layout.dur || 0)); if (nextPrePaintIndex === null) { // No PrePaint event registered after this LayoutInvalidation. Continue. continue; } const nextPrePaint = prePaintEvents[nextPrePaintIndex]; const subsequentShifts = shiftsByPrePaint.get(nextPrePaint); if (!subsequentShifts) { // The PrePaint after the current LayoutInvalidation doesn't contain shifts. continue; } const layoutNode = modelData.Renderer.entryToNode.get(layout); const initiator = layoutNode ? modelData.Initiators.eventToInitiator.get(layoutNode.entry) : null; const stackTrace = initiator?.args?.data?.stackTrace; if (!stackTrace) { continue; } // Add found potential root causes to all the shifts in this PrePaint and populate the cache. for (const shift of subsequentShifts) { const rootCausesForShift = Platform.MapUtilities.getWithDefault(this.#rootCauseCacheMap, shift, () => { return { unsizedMedia: [], iframes: [], fontChanges: [], renderBlockingRequests: [], scriptStackTrace: [], }; }); if (rootCausesForShift.scriptStackTrace.length === 0) { rootCausesForShift.scriptStackTrace = stackTrace; } } } } /** * Given a LayoutInvalidation trace event, determines if it was dispatched * because a media element without dimensions was resized. */ async getUnsizedMediaRootCause( layoutInvalidation: Types.Events.LayoutInvalidationTracking, layoutInvalidationNodeId: Protocol.DOM.NodeId): Promise<UnsizedMedia|null> { // Filter events to resizes only. if (layoutInvalidation.args.data.reason !== Types.Events.LayoutInvalidationReason.SIZE_CHANGED) { return null; } const layoutInvalidationNode = await this.getNodeDetails(layoutInvalidationNodeId); if (!layoutInvalidationNode) { return null; } const computedStylesList = await this.#protocolInterface.getComputedStyleForNode(layoutInvalidationNode.nodeId); const computedStyles = new Map(computedStylesList.map(item => [item.name, item.value])); if (computedStyles && !(await nodeIsUnfixedMedia(layoutInvalidationNode, computedStyles))) { return null; } const authoredDimensions = await this.getNodeAuthoredDimensions(layoutInvalidationNode); if (dimensionsAreExplicit(authoredDimensions)) { return null; } const computedDimensions = computedStyles ? getNodeComputedDimensions(computedStyles) : {}; return {node: layoutInvalidationNode, authoredDimensions, computedDimensions}; } /** * Given a LayoutInvalidation trace event, determines if it was dispatched * because a node, which is an ancestor to an iframe, was injected. */ async getIframeRootCause( layoutInvalidation: Types.Events.LayoutInvalidationTracking, layoutInvalidationNodeId: Protocol.DOM.NodeId): Promise<InjectedIframe|null> { if (!this.#iframeRootCausesEnabled) { return null; } if (!layoutInvalidation.args.data.nodeName?.startsWith('IFRAME') && layoutInvalidation.args.data.reason !== Types.Events.LayoutInvalidationReason.STYLE_CHANGED && layoutInvalidation.args.data.reason !== Types.Events.LayoutInvalidationReason.ADDED_TO_LAYOUT) { return null; } const layoutInvalidationNode = await this.getNodeDetails(layoutInvalidationNodeId); if (!layoutInvalidationNode) { return null; } const iframe = firstIframeInDOMTree(layoutInvalidationNode); if (!iframe) { return null; } return {iframe}; } async getNodeDetails(nodeId: Protocol.DOM.NodeId): Promise<Protocol.DOM.Node|null> { let nodeDetails = this.#nodeDetailsCache.get(nodeId); if (nodeDetails !== undefined) { return nodeDetails; } nodeDetails = await this.#protocolInterface.getNode(nodeId); this.#nodeDetailsCache.set(nodeId, nodeDetails); return nodeDetails; } /** * Given a layout invalidation event and a sorted array, returns the subset of requests that arrived within a * 500ms window before the layout invalidation. */ requestsInInvalidationWindow( layoutInvalidation: Types.Events.LayoutInvalidationTracking|Types.Events.ScheduleStyleInvalidationTracking, modelData: ParsedTrace): RootCauseRequest[] { const requestsSortedByEndTime = modelData.NetworkRequests.byTime.sort((req1, req2) => { const req1EndTime = req1.ts + req1.dur; const req2EndTime = req2.ts + req2.dur; return req1EndTime - req2EndTime; }); const lastRequestIndex = Platform.ArrayUtilities.nearestIndexFromEnd( requestsSortedByEndTime, request => request.ts + request.dur < layoutInvalidation.ts); if (lastRequestIndex === null) { return []; } const MAX_DELTA_FOR_FONT_REQUEST = Helpers.Timing.secondsToMicro(Types.Timing.Seconds(0.5)); const requestsInInvalidationWindow: RootCauseRequest[] = []; // Get all requests finished within the valid window. for (let i = lastRequestIndex; i > -1; i--) { const previousRequest = requestsSortedByEndTime[i]; const previousRequestEndTime = previousRequest.ts + previousRequest.dur; if (layoutInvalidation.ts - previousRequestEndTime < MAX_DELTA_FOR_FONT_REQUEST) { const requestInInvalidationWindow: RootCauseRequest = {request: previousRequest}; const initiator = this.#protocolInterface.getInitiatorForRequest( previousRequest.args.data.url as Platform.DevToolsPath.UrlString); requestInInvalidationWindow.initiator = initiator || undefined; requestsInInvalidationWindow.push(requestInInvalidationWindow); } else { // No more requests fit in the time window. break; } } return requestsInInvalidationWindow; } /** * Given a LayoutInvalidation trace event, determines if it was dispatched * because fonts were changed and if so returns the information of all network * request with which the fonts were possibly fetched, if any. The computed * network requests are cached for the corresponding prepaint event, meaning * that other LayoutInvalidation events that correspond to the same prepaint * are not processed and the cached network requests for the prepaint is * returned instead. */ getFontChangeRootCause( layoutInvalidation: Types.Events.LayoutInvalidationTracking|Types.Events.ScheduleStyleInvalidationTracking, nextPrePaint: Types.Events.PrePaint, modelData: ParsedTrace): FontChange[]|null { if (layoutInvalidation.args.data.reason !== Types.Events.LayoutInvalidationReason.FONTS_CHANGED) { return null; } // Prevent computing the result of this function multiple times per PrePaint event. const fontRequestsForPrepaint = fontRequestsByPrePaint.get(nextPrePaint); if (fontRequestsForPrepaint !== undefined) { return fontRequestsForPrepaint; } const fontRequestsInThisPrepaint = this.getFontRequestsInInvalidationWindow(this.requestsInInvalidationWindow(layoutInvalidation, modelData)); fontRequestsByPrePaint.set(nextPrePaint, fontRequestsInThisPrepaint); return fontRequestsInThisPrepaint; } /** * Given the requests that arrived within a 500ms window before the layout invalidation, returns the font * requests of them. */ getFontRequestsInInvalidationWindow(requestsInInvalidationWindow: RootCauseRequest[]): FontChange[] { const fontRequests: FontChange[] = []; // Get all requests finished within the valid window. for (let i = 0; i < requestsInInvalidationWindow.length; i++) { const fontRequest = requestsInInvalidationWindow[i] as FontChange; if (!fontRequest.request.args.data.mimeType.startsWith('font')) { continue; } const fontFace = this.#protocolInterface.fontFaceForSource(fontRequest.request.args.data.url); if (!fontFace || fontFace.fontDisplay === 'optional') { // Setting font-display to optional is part of what the developer // can do to avoid layout shifts due to FOIT/FOUT, as such we cannot // suggest any actionable insight here. continue; } fontRequest.fontFace = fontFace; fontRequests.push(fontRequest); } return fontRequests; } /** * Given a LayoutInvalidation trace event, determines if it arrived within a 500ms window before the layout * invalidation and if so returns the information of all network request, if any. The computed network * requests are cached for the corresponding prepaint event, meaning that other LayoutInvalidation events * that correspond to the same prepaint are not processed and the cached network requests for the prepaint is * returned instead. */ getRenderBlockRootCause( layoutInvalidation: Types.Events.LayoutInvalidationTracking|Types.Events.ScheduleStyleInvalidationTracking, nextPrePaint: Types.Events.PrePaint, modelData: ParsedTrace): RenderBlockingRequest[]|null { // Prevent computing the result of this function multiple times per PrePaint event. const renderBlocksInPrepaint = renderBlocksByPrePaint.get(nextPrePaint); if (renderBlocksInPrepaint !== undefined) { return renderBlocksInPrepaint; } const renderBlocksInThisPrepaint = getRenderBlockRequestsInInvalidationWindow(this.requestsInInvalidationWindow(layoutInvalidation, modelData)); renderBlocksByPrePaint.set(nextPrePaint, renderBlocksInThisPrepaint); return renderBlocksInThisPrepaint; } /** * Returns a function that retrieves the active value of a given * CSS property within the matched styles of the param node. * The first occurrence within the matched styles is returned and the * value is looked up in the following order, which follows CSS * specificity: * 1. Inline styles. * 2. CSS rules matching this node, from all applicable stylesheets. * 3. Attribute defined styles. */ async nodeMatchedStylesPropertyGetter(node: Protocol.DOM.Node): Promise<((property: string) => string | null)> { const response = await this.#protocolInterface.getMatchedStylesForNode(node.nodeId); function cssPropertyValueGetter(cssProperty: string): string|null { let prop = response.inlineStyle?.cssProperties.find(prop => prop.name === cssProperty); if (prop) { return prop.value; } for (const {rule} of response.matchedCSSRules || []) { const prop = rule.style.cssProperties.find(prop => prop.name === cssProperty); if (prop) { return prop.value; } } prop = response.attributesStyle?.cssProperties.find(prop => prop.name === cssProperty); if (prop) { return prop.value; } return null; } return cssPropertyValueGetter; } /** * Returns the CSS dimensions set to the node from its matched styles. */ async getNodeAuthoredDimensions(node: Protocol.DOM.Node): Promise<CSSDimensions> { const authoredDimensions: CSSDimensions = {}; const cssMatchedRulesGetter = await this.nodeMatchedStylesPropertyGetter(node); if (!cssMatchedRulesGetter) { return authoredDimensions; } const attributesFlat = node.attributes || []; const attributes = []; for (let i = 0; i < attributesFlat.length; i += 2) { attributes.push({name: attributesFlat[i], value: attributesFlat[i + 1]}); } const htmlHeight = attributes.find(attr => attr.name === 'height' && htmlAttributeIsExplicit(attr)); const htmlWidth = attributes.find(attr => attr.name === 'width' && htmlAttributeIsExplicit(attr)); const cssExplicitAspectRatio = cssMatchedRulesGetter('aspect-ratio') || undefined; if (htmlHeight && htmlWidth && cssExplicitAspectRatio) { return {height: htmlHeight.value, width: htmlWidth.value, aspectRatio: cssExplicitAspectRatio}; } const cssHeight = cssMatchedRulesGetter('height') || undefined; const cssWidth = cssMatchedRulesGetter('width') || undefined; return {height: cssHeight, width: cssWidth, aspectRatio: cssExplicitAspectRatio}; } } /** * Given the requests that arrived within a 500ms window before the layout invalidation, returns the render * block requests of them. */ function getRenderBlockRequestsInInvalidationWindow(requestsInInvalidationWindow: RootCauseRequest[]): RenderBlockingRequest[] { const renderBlockingRequests: RenderBlockingRequest[] = []; // Get all requests finished within the valid window. for (let i = 0; i < requestsInInvalidationWindow.length; i++) { const mainFrameId = requestsInInvalidationWindow[i].request.args.data.frame; if (!networkRequestIsRenderBlockingInFrame(requestsInInvalidationWindow[i].request, mainFrameId)) { continue; } renderBlockingRequests.push(requestsInInvalidationWindow[i] as RenderBlockingRequest); } return renderBlockingRequests; } function firstIframeInDOMTree(root: Protocol.DOM.Node): Protocol.DOM.Node|null { if (root.nodeName === 'IFRAME') { return root; } const children = root.children; if (!children) { return null; } for (const child of children) { const iFrameInChild = firstIframeInDOMTree(child); if (iFrameInChild) { return iFrameInChild; } } return null; } function cssPropertyIsExplicitlySet(propertyValue: string): boolean { return !['auto', 'initial', 'unset', 'inherit'].includes(propertyValue); } function htmlAttributeIsExplicit(attr: {value: string}): boolean { return parseInt(attr.value, 10) >= 0; } function computedStyleHasBackroundImage(computedStyle: Map<string, string>): boolean { const CSS_URL_REGEX = /^url\("([^"]+)"\)$/; const backgroundImage = computedStyle.get('background-image'); if (!backgroundImage) { return false; } return CSS_URL_REGEX.test(backgroundImage); } function computedStyleHasFixedPosition(computedStyle: Map<string, string>): boolean { const position = computedStyle.get('position'); if (!position) { return false; } return position === 'fixed' || position === 'absolute'; } function getNodeComputedDimensions(computedStyle: Map<string, string>): CSSDimensions { const computedDimensions: CSSDimensions = {}; computedDimensions.height = computedStyle.get('height'); computedDimensions.width = computedStyle.get('width'); computedDimensions.aspectRatio = computedStyle.get('aspect-ratio'); return computedDimensions; } /** * Determines if a node is a media element and is not fixed positioned * (i.e. "position: fixed;" or "position: absolute;") */ async function nodeIsUnfixedMedia(node: Protocol.DOM.Node, computedStyle: Map<string, string>): Promise<boolean> { const localName = node.localName; const isBackgroundImage = computedStyleHasBackroundImage(computedStyle); if (localName !== 'img' && localName !== 'video' && !isBackgroundImage) { // Not a media element. return false; } const isFixed = computedStyleHasFixedPosition(computedStyle); return !isFixed; } /** * Determines if a CSS dimensions object explicitly defines both width and height * (i.e. not set to auto, inherit, etc.) */ function dimensionsAreExplicit(dimensions: CSSDimensions): boolean { const {height, width, aspectRatio} = dimensions; const explicitHeight = Boolean(height && cssPropertyIsExplicitlySet(height)); const explicitWidth = Boolean(width && cssPropertyIsExplicitlySet(width)); const explicitAspectRatio = Boolean(aspectRatio && cssPropertyIsExplicitlySet(aspectRatio)); const explicitWithAR = (explicitHeight || explicitWidth) && explicitAspectRatio; return (explicitHeight && explicitWidth) || explicitWithAR; } /** * 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 the end of this PrePaint. Continue to the next one. break; } } } return shiftsByPrePaint; }