chrome-devtools-frontend
Version:
Chrome DevTools UI
525 lines (477 loc) • 22.6 kB
text/typescript
// 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};
}));
}
}