UNPKG

chrome-devtools-frontend

Version:
525 lines (477 loc) 22.6 kB
// Copyright 2024 The Chromium Authors // Use of this source code is governed by a BSD-style license that can be // found in the LICENSE file. import '../../ui/components/linkifier/linkifier.js'; import '../../ui/legacy/components/data_grid/data_grid.js'; import * as i18n from '../../core/i18n/i18n.js'; import * as SDK from '../../core/sdk/sdk.js'; import type * as Protocol from '../../generated/protocol.js'; import * as Trace from '../../models/trace/trace.js'; import type * as Linkifier from '../../ui/components/linkifier/linkifier.js'; import * as UI from '../../ui/legacy/legacy.js'; import {html, render} from '../../ui/lit/lit.js'; import * as VisualLogging from '../../ui/visual_logging/visual_logging.js'; import timelineSelectorStatsViewStyles from './timelineSelectorStatsView.css.js'; import * as Utils from './utils/utils.js'; const UIStrings = { /** * @description Accessible name for the selector stats data grid in the Performance panel. */ selectorStats: 'Selector stats', /** * @description Column header for elapsed time in the selector stats data grid. */ elapsed: 'Elapsed (ms)', /** * @description Tooltip explanation for the elapsed time column in the selector stats data grid. */ elapsedExplanation: 'Elapsed time spent matching a selector against the DOM in milliseconds', /** * @description Column header for the percentage of slow-path non-matches in the selector stats data grid. */ slowPathNonMatches: '% of slow-path non-matches', /** * @description Tooltip explanation for the slow-path non-matches column in the selector stats data grid. */ slowPathNonMatchesExplanation: 'The percentage of non-matching nodes (match attempts − match count) that couldn’t be quickly ruled out by the bloom filter due to high selector complexity. Lower is better.', /** * @description Column header for match attempts in the selector stats data grid. */ matchAttempts: 'Match attempts', /** * @description Tooltip explanation for the match attempts column in the selector stats data grid. */ matchAttemptsExplanation: 'Count of nodes that the engine attempted to match against a style rule', /** * @description Column header for match count in the selector stats data grid. */ matchCount: 'Match count', /** * @description Tooltip explanation for the match count column in the selector stats data grid. */ matchCountExplanation: 'Count of nodes that matched a style rule', /** * @description Column header for the CSS selector in the selector stats data grid. */ selector: 'Selector', /** * @description Tooltip explanation for the selector column in the selector stats data grid. */ selectorExplanation: 'CSS selector text of a style rule', /** * @description Column header for the stylesheet link in the selector stats data grid. */ styleSheetId: 'Style sheet', /** * @description Tooltip explanation for the style sheet column in the selector stats data grid. */ styleSheetIdExplanation: 'Links to the selector rule definition in the style sheets. Note that a selector rule could be defined in multiple places in a style sheet or defined in multiple style sheets. Selector rules from browser user-agent style sheet or dynamic style sheets don’t have a link.', /** * @description Context menu item to copy the selector stats table to clipboard. */ copyTable: 'Copy table', /** * @description Cell value displayed when a stylesheet source file cannot be linked. */ unableToLink: 'Unable to link', /** * @description Tooltip displayed when a stylesheet source file cannot be linked via its ID. * @example {style-sheet-4} PH1 */ unableToLinkViaStyleSheetId: 'Unable to link via {PH1}', /** * @description Screen reader announcement when the selector stats table is copied to clipboard. */ tableCopiedToClipboard: 'Table copied to clipboard', /** * @description Row description for the aggregated totals of all selectors in the selector stats data grid. */ totalForAllSelectors: '(Totals for all selectors)', /** * @description Link text showing the line and column number of a selector rule in a stylesheet. * @example {256} PH1 * @example {14} PH2 */ lineNumber: 'Line {PH1}:{PH2}', /** * @description Column header for invalidation count in the selector stats data grid. */ invalidationCount: 'Invalidation count', /** * @description Tooltip explanation for the invalidation count column in the selector stats data grid. */ invalidationCountExplanation: 'Aggregated count of invalidations on nodes and subsequently had style recalculated, all of which are matched by this selector. Note that a node can be invalidated multiple times and by multiple selectors.', } as const; const str_ = i18n.i18n.registerUIStrings('panels/timeline/TimelineSelectorStatsView.ts', UIStrings); const i18nString = i18n.i18n.getLocalizedString.bind(undefined, str_); const SelectorTimingsKey = Trace.Types.Events.SelectorTimingsKey; type SelectorTiming = Trace.Types.Events.SelectorTiming&{locations: Linkifier.Linkifier.LinkifierData[] | undefined | null}; interface ViewInput { timings: SelectorTiming[]; onContextMenu: (event: CustomEvent<{menu: UI.ContextMenu.ContextMenu, element: HTMLElement}>) => void; } type View = (input: ViewInput, output: object, target: HTMLElement) => void; const DEFAULT_VIEW: View = (input, _output, target) => { render( html` <devtools-data-grid striped name=${i18nString(UIStrings.selectorStats)} @contextmenu=${input.onContextMenu.bind(input)}> <table> <tr> <th id=${SelectorTimingsKey.Elapsed} weight="1" sortable hideable align="right"> <span title=${i18nString(UIStrings.elapsedExplanation)}> ${i18nString(UIStrings.elapsed)}</span> </th> <th id=${SelectorTimingsKey.InvalidationCount} weight="1.5" sortable hideable> <span title=${i18nString(UIStrings.invalidationCountExplanation)}>${ i18nString(UIStrings.invalidationCount)}</span> </th> <th id=${SelectorTimingsKey.MatchAttempts} weight="1" sortable hideable align="right"> <span title=${i18nString(UIStrings.matchAttemptsExplanation)}> ${i18nString(UIStrings.matchAttempts)}</span> </th> <th id=${SelectorTimingsKey.MatchCount} weight="1" sortable hideable align="right"> <span title=${i18nString(UIStrings.matchCountExplanation)}> ${i18nString(UIStrings.matchCount)}</span> </th> <th id=${SelectorTimingsKey.RejectPercentage} weight="1" sortable hideable align="right"> <span title=${i18nString(UIStrings.slowPathNonMatchesExplanation)}>${ i18nString(UIStrings.slowPathNonMatches)}</span> </th> <th id=${SelectorTimingsKey.Selector} weight="3" sortable hideable> <span title=${i18nString(UIStrings.selectorExplanation)}> ${i18nString(UIStrings.selector)}</span> </th> <th id=${SelectorTimingsKey.StyleSheetId} weight="1.5" sortable hideable> <span title=${i18nString(UIStrings.styleSheetIdExplanation)}> ${i18nString(UIStrings.styleSheetId)}</span> </th> </tr> ${input.timings.map(timing => { const nonMatches = timing[SelectorTimingsKey.MatchAttempts] - timing[SelectorTimingsKey.MatchCount]; const slowPathNonMatches = (nonMatches ? 1.0 - timing[SelectorTimingsKey.FastRejectCount] / nonMatches : 0) * 100; const styleSheetId = timing[SelectorTimingsKey.StyleSheetId]; const locations = timing.locations; const locationMessage = locations ? null : locations === null ? '' : i18nString(UIStrings.unableToLinkViaStyleSheetId, {PH1: styleSheetId}); return html`<tr> <td data-value=${timing[SelectorTimingsKey.Elapsed]}> ${(timing[SelectorTimingsKey.Elapsed] / 1000.0).toFixed(3)} </td> <td title=${timing[SelectorTimingsKey.InvalidationCount]}> ${timing[SelectorTimingsKey.InvalidationCount]} </td> <td>${timing[SelectorTimingsKey.MatchAttempts]}</td> <td>${timing[SelectorTimingsKey.MatchCount]}</td> <td data-value=${slowPathNonMatches}> ${slowPathNonMatches.toFixed(1)} </td> <td title=${timing[SelectorTimingsKey.Selector]}> ${timing[SelectorTimingsKey.Selector]} </td> <td data-value=${styleSheetId}>${ locations ? html`${locations.map((location, itemIndex) => html` <devtools-linkifier .data=${location}></devtools-linkifier >${itemIndex !== locations.length - 1 ? ',' : ''}`)}` : locationMessage} </td> </tr>`; })} </table> </devtools-data-grid>`, target, {container: {attributes: {jslog: `${VisualLogging.pane('selector-stats').track({resize: true})}`}}}); }; export class TimelineSelectorStatsView extends UI.Widget.VBox { #selectorLocations: Map<string, Protocol.CSS.SourceRange[]>; #parsedTrace: Trace.TraceModel.ParsedTrace|null = null; /** * We store the last event (or array of events) that we renderered. We do * this because as the user zooms around the panel this view is updated, * however if the set of events that are populating the view is the same as it * was the last time, we can bail without doing any re-rendering work. * If the user views a single event, this will be set to that single event, but if they are viewing a range of events, this will be set to an array. * If it's null, that means we have not rendered yet. */ #lastStatsSourceEventOrEvents: Trace.Types.Events.RecalcStyle|Trace.Types.Events.RecalcStyle[]|null = null; #view: View; #timings: SelectorTiming[] = []; constructor(parsedTrace: Trace.TraceModel.ParsedTrace|null, view: View = DEFAULT_VIEW) { super(); this.registerRequiredCSS(timelineSelectorStatsViewStyles); this.#view = view; this.#selectorLocations = new Map<string, Protocol.CSS.SourceRange[]>(); this.#parsedTrace = parsedTrace; this.performUpdate(); } #onContextMenu(e: CustomEvent<{menu: UI.ContextMenu.ContextMenu, element: HTMLElement}>): void { const {menu} = e.detail; menu.defaultSection().appendItem(i18nString(UIStrings.copyTable), () => { const tableData = []; const columnName = [ i18nString(UIStrings.elapsed), i18nString(UIStrings.matchAttempts), i18nString(UIStrings.matchCount), i18nString(UIStrings.slowPathNonMatches), i18nString(UIStrings.selector), i18nString(UIStrings.styleSheetId), ]; tableData.push(columnName.join('\t')); for (const timing of this.#timings) { const nonMatches = timing[SelectorTimingsKey.MatchAttempts] - timing[SelectorTimingsKey.MatchCount]; const slowPathNonMatches = (nonMatches ? 1.0 - timing[SelectorTimingsKey.FastRejectCount] / nonMatches : 0) * 100; const styleSheetId = timing[SelectorTimingsKey.StyleSheetId] as Protocol.DOM.StyleSheetId; let linkData = ''; const target = SDK.TargetManager.TargetManager.instance().primaryPageTarget(); const cssModel = target?.model(SDK.CSSModel.CSSModel); if (cssModel) { const styleSheetHeader = cssModel.styleSheetHeaderForId(styleSheetId); if (styleSheetHeader) { linkData = styleSheetHeader.resourceURL().toString(); } } if (!linkData) { linkData = i18nString(UIStrings.unableToLink); } tableData.push([ timing[SelectorTimingsKey.Elapsed] / 1000.0, timing[SelectorTimingsKey.MatchAttempts], timing[SelectorTimingsKey.MatchCount], slowPathNonMatches, timing[SelectorTimingsKey.Selector], linkData, ].join('\t')); } const data = tableData.join('\n'); UI.UIUtils.copyTextToClipboard(data, i18nString(UIStrings.tableCopiedToClipboard)); }); } override performUpdate(): void { const viewInput = { timings: this.#timings, onContextMenu: (event: CustomEvent<{menu: UI.ContextMenu.ContextMenu, element: HTMLElement}>) => { this.#onContextMenu(event); }, }; const viewOutput = {}; this.#view(viewInput, viewOutput, this.contentElement); } private getDescendentNodeCount(node: SDK.DOMModel.DOMNode|null): number { if (!node) { return 0; } // number of descendent nodes, including self let numberOfDescendentNode = 1; const childNodes = node.children(); if (childNodes) { for (const childNode of childNodes) { numberOfDescendentNode += this.getDescendentNodeCount(childNode); } } return numberOfDescendentNode; } private async updateInvalidationCount(events: Trace.Types.Events.RecalcStyle[]): Promise<void> { if (!this.#parsedTrace) { return; } const invalidatedNodes = this.#parsedTrace.data.SelectorStats.invalidatedNodeList; const invalidatedNodeMap = new Map<string, {subtree: boolean, nodeList: Array<SDK.DOMModel.DOMNode|null>}>(); const frameIdBackendNodeIdsMap = new Map<String, Set<Protocol.DOM.BackendNodeId>>(); for (const {frame, backendNodeId} of invalidatedNodes) { if (!frameIdBackendNodeIdsMap.has(frame)) { frameIdBackendNodeIdsMap.set(frame, new Set<Protocol.DOM.BackendNodeId>()); } frameIdBackendNodeIdsMap.get(frame)?.add(backendNodeId); } const invalidatedNodeIdMap = new Map<String, Map<Protocol.DOM.BackendNodeId, SDK.DOMModel.DOMNode|null>>(); for (const [frameId, backendNodeIds] of frameIdBackendNodeIdsMap) { const backendNodeIdMap = await Utils.EntryNodes.domNodesForBackendIds(frameId as Protocol.Page.FrameId, backendNodeIds); invalidatedNodeIdMap.set(frameId, backendNodeIdMap); } for (const invalidatedNode of invalidatedNodes) { const invalidatedNodeDomNode = invalidatedNodeIdMap.get(invalidatedNode.frame)?.get(invalidatedNode.backendNodeId) ?? null; // aggregate invalidated nodes per (Selector + Recalc timestamp + Frame) for (const selector of invalidatedNode.selectorList) { const key = [ selector.selector, selector.styleSheetId, invalidatedNode.frame, invalidatedNode.lastRecalcStyleEventTs, ].join('-'); if (invalidatedNodeMap.has(key)) { const nodes = invalidatedNodeMap.get(key); nodes?.nodeList.push(invalidatedNodeDomNode); } else { invalidatedNodeMap.set(key, {subtree: invalidatedNode.subtree, nodeList: [invalidatedNodeDomNode]}); } } } for (const event of events) { const selectorStats = event ? this.#parsedTrace.data.SelectorStats.dataForRecalcStyleEvent.get(event) : undefined; if (!selectorStats) { continue; } const frameId = event.args.beginData?.frame; for (const timing of selectorStats.timings) { timing.invalidation_count = 0; const key = [timing.selector, timing.style_sheet_id, frameId, event.ts].join('-'); const nodes = invalidatedNodeMap.get(key); if (nodes === undefined) { continue; } for (const node of nodes.nodeList) { if (nodes.subtree) { // TODO: this count is live and might have changed since the trace event. timing.invalidation_count += this.getDescendentNodeCount(node); } else { timing.invalidation_count += 1; } } } } } private async aggregateEvents(events: Trace.Types.Events.RecalcStyle[]): Promise<void> { if (!this.#parsedTrace) { return; } const timings: Trace.Types.Events.SelectorTiming[] = []; const selectorMap = new Map<String, Trace.Types.Events.SelectorTiming>(); const sums = { [SelectorTimingsKey.Elapsed]: 0, [SelectorTimingsKey.MatchAttempts]: 0, [SelectorTimingsKey.MatchCount]: 0, [SelectorTimingsKey.FastRejectCount]: 0, [SelectorTimingsKey.InvalidationCount]: 0, }; // Now we want to check if the set of events we have been given matches the // set of events we last rendered. We can't just compare the arrays because // they will be different events, so instead for each event in the new // array we see if it has a match in the old set of events at the same // index. if (Array.isArray(this.#lastStatsSourceEventOrEvents)) { if (this.#lastStatsSourceEventOrEvents.length === events.length && events.every((event, index) => { // This is true due to the isArray check, but without this cast TS // would want us to repeat the isArray() check inside this callback, // but we want to avoid that extra work. const previousEvents = this.#lastStatsSourceEventOrEvents as Trace.Types.Events.RecalcStyle[]; return event === previousEvents[index]; })) { return; } } this.#lastStatsSourceEventOrEvents = events; await this.updateInvalidationCount(events); for (let i = 0; i < events.length; i++) { const event = events[i]; const selectorStats = event ? this.#parsedTrace.data.SelectorStats.dataForRecalcStyleEvent.get(event) : undefined; if (!selectorStats) { continue; } const data: Trace.Types.Events.SelectorTiming[] = selectorStats.timings; for (const timing of data) { const key = timing[SelectorTimingsKey.Selector] + '_' + timing[SelectorTimingsKey.StyleSheetId]; const findTiming = selectorMap.get(key); if (findTiming !== undefined) { findTiming[SelectorTimingsKey.Elapsed] += timing[SelectorTimingsKey.Elapsed]; findTiming[SelectorTimingsKey.FastRejectCount] += timing[SelectorTimingsKey.FastRejectCount]; findTiming[SelectorTimingsKey.MatchAttempts] += timing[SelectorTimingsKey.MatchAttempts]; findTiming[SelectorTimingsKey.MatchCount] += timing[SelectorTimingsKey.MatchCount]; findTiming[SelectorTimingsKey.InvalidationCount] += timing[SelectorTimingsKey.InvalidationCount]; } else { selectorMap.set(key, structuredClone(timing)); } // Keep track of the total times for a sum row. sums[SelectorTimingsKey.Elapsed] += timing[SelectorTimingsKey.Elapsed]; sums[SelectorTimingsKey.MatchAttempts] += timing[SelectorTimingsKey.MatchAttempts]; sums[SelectorTimingsKey.MatchCount] += timing[SelectorTimingsKey.MatchCount]; sums[SelectorTimingsKey.FastRejectCount] += timing[SelectorTimingsKey.FastRejectCount]; sums[SelectorTimingsKey.InvalidationCount] += timing[SelectorTimingsKey.InvalidationCount]; } } if (selectorMap.size > 0) { selectorMap.forEach(timing => { timings.push(timing); }); selectorMap.clear(); } else { this.#timings = []; return; } // Add the sum row. timings.unshift({ [SelectorTimingsKey.Elapsed]: sums[SelectorTimingsKey.Elapsed], [SelectorTimingsKey.FastRejectCount]: sums[SelectorTimingsKey.FastRejectCount], [SelectorTimingsKey.MatchAttempts]: sums[SelectorTimingsKey.MatchAttempts], [SelectorTimingsKey.MatchCount]: sums[SelectorTimingsKey.MatchCount], [SelectorTimingsKey.Selector]: i18nString(UIStrings.totalForAllSelectors), [SelectorTimingsKey.StyleSheetId]: 'n/a', [SelectorTimingsKey.InvalidationCount]: sums[SelectorTimingsKey.InvalidationCount], }); this.#timings = await this.processSelectorTimings(timings); } setAggregatedEvents(events: Trace.Types.Events.RecalcStyle[]): void { if (!this.#parsedTrace) { return; } void this.aggregateEvents(events).then(() => { this.requestUpdate(); }); } private async processSelectorTimings(timings: Trace.Types.Events.SelectorTiming[]): Promise<SelectorTiming[]> { async function toSourceFileLocation( cssModel: SDK.CSSModel.CSSModel, styleSheetId: Protocol.DOM.StyleSheetId, selectorText: string, selectorLocations: Map<string, Protocol.CSS.SourceRange[]>): Promise<Linkifier.Linkifier.LinkifierData[]|undefined> { if (!cssModel) { return undefined; } const styleSheetHeader = cssModel.styleSheetHeaderForId(styleSheetId); if (!styleSheetHeader?.resourceURL()) { return undefined; } // get the locations from cache if available const key: string = JSON.stringify({selectorText, styleSheetId}); let ranges = selectorLocations.get(key); if (!ranges) { const result = await cssModel.agent.invoke_getLocationForSelector({styleSheetId, selectorText}); if (result.getError() || !result.ranges) { return undefined; } ranges = result.ranges; selectorLocations.set(key, ranges); } const linkData = ranges.map(range => { return { url: styleSheetHeader.resourceURL(), lineNumber: range.startLine, columnNumber: range.startColumn, linkText: i18nString(UIStrings.lineNumber, {PH1: range.startLine + 1, PH2: range.startColumn + 1}), title: `${styleSheetHeader.id} line ${range.startLine + 1}:${range.startColumn + 1}`, } as Linkifier.Linkifier.LinkifierData; }); return linkData; } const target = SDK.TargetManager.TargetManager.instance().primaryPageTarget(); const cssModel = target?.model(SDK.CSSModel.CSSModel); if (!cssModel) { return []; } return await Promise.all( timings.sort((a, b) => b[SelectorTimingsKey.Elapsed] - a[SelectorTimingsKey.Elapsed]).map(async x => { const styleSheetId = x[SelectorTimingsKey.StyleSheetId] as Protocol.DOM.StyleSheetId; const selectorText = x[SelectorTimingsKey.Selector].trim(); const locations = styleSheetId === 'n/a' ? null : await toSourceFileLocation(cssModel, styleSheetId, selectorText, this.#selectorLocations); return {...x, locations}; })); } }