chrome-devtools-frontend
Version:
Chrome DevTools UI
454 lines (410 loc) • 17.1 kB
text/typescript
// 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.
/* eslint-disable rulesdir/no-lit-render-outside-of-view */
import * as i18n from '../../../core/i18n/i18n.js';
import type * as Platform from '../../../core/platform/platform.js';
import * as SDK from '../../../core/sdk/sdk.js';
import type * as Protocol from '../../../generated/protocol.js';
import * as Helpers from '../../../models/trace/helpers/helpers.js';
import * as Trace from '../../../models/trace/trace.js';
import * as Buttons from '../../../ui/components/buttons/buttons.js';
import * as LegacyComponents from '../../../ui/legacy/components/utils/utils.js';
import * as Lit from '../../../ui/lit/lit.js';
import * as Utils from '../utils/utils.js';
import * as Insights from './insights/insights.js';
import layoutShiftDetailsStyles from './layoutShiftDetails.css.js';
const {html} = Lit;
const MAX_URL_LENGTH = 20;
const UIStrings = {
/**
* @description Text referring to the start time of a given event.
*/
startTime: 'Start time',
/**
* @description Text for a table header referring to the score of a Layout Shift event.
*/
shiftScore: 'Shift score',
/**
* @description Text for a table header referring to the elements shifted for a Layout Shift event.
*/
elementsShifted: 'Elements shifted',
/**
* @description Text for a table header referring to the culprit of a Layout Shift event.
*/
culprit: 'Culprit',
/**
* @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 non-composited animation.
*/
nonCompositedAnimation: 'Non-composited animation',
/**
* @description Text referring to an animation.
*/
animation: 'Animation',
/**
* @description Text referring to a parent cluster.
*/
parentCluster: 'Parent cluster',
/**
* @description Text referring to a layout shift cluster and its start time.
* @example {32 ms} PH1
*/
cluster: 'Layout shift cluster @ {PH1}',
/**
* @description Text referring to a layout shift and its start time.
* @example {32 ms} PH1
*/
layoutShift: 'Layout shift @ {PH1}',
/**
* @description Text referring to the total cumulative score of a layout shift cluster.
*/
total: 'Total',
/**
* @description Text for a culprit type of Unsized image.
*/
unsizedImage: 'Unsized image',
} as const;
const str_ = i18n.i18n.registerUIStrings('panels/timeline/components/LayoutShiftDetails.ts', UIStrings);
const i18nString = i18n.i18n.getLocalizedString.bind(undefined, str_);
export class LayoutShiftDetails extends HTMLElement {
readonly #shadow = this.attachShadow({mode: 'open'});
#event: Trace.Types.Events.SyntheticLayoutShift|Trace.Types.Events.SyntheticLayoutShiftCluster|null = null;
#traceInsightsSets: Trace.Insights.Types.TraceInsightSets|null = null;
#parsedTrace: Trace.Handlers.Types.ParsedTrace|null = null;
#isFreshRecording = false;
connectedCallback(): void {
this.#render();
}
setData(
event: Trace.Types.Events.SyntheticLayoutShift|Trace.Types.Events.SyntheticLayoutShiftCluster,
traceInsightsSets: Trace.Insights.Types.TraceInsightSets|null, parsedTrace: Trace.Handlers.Types.ParsedTrace|null,
isFreshRecording: boolean): void {
if (this.#event === event) {
return;
}
this.#event = event;
this.#traceInsightsSets = traceInsightsSets;
this.#parsedTrace = parsedTrace;
this.#isFreshRecording = isFreshRecording;
this.#render();
}
#renderTitle(event: Trace.Types.Events.SyntheticLayoutShift|Trace.Types.Events.SyntheticLayoutShiftCluster):
Lit.TemplateResult {
const title = Utils.EntryName.nameForEntry(event);
return html`
<div class="layout-shift-details-title">
<div class="layout-shift-event-title"></div>
${title}
</div>
`;
}
#renderShiftedElements(
shift: Trace.Types.Events.SyntheticLayoutShift,
elementsShifted: Trace.Types.Events.TraceImpactedNode[]|undefined): Lit.LitTemplate {
// clang-format off
return html`
${elementsShifted?.map(el => {
if (el.node_id !== undefined) {
return html`
<devtools-performance-node-link
.data=${{
backendNodeId: el.node_id,
frame: shift.args.frame,
// TODO(crbug.com/371620361): if ever rendered for non-fresh traces, this needs to set a fallback text value.
} as Insights.NodeLink.NodeLinkData}>
</devtools-performance-node-link>`;
}
return Lit.nothing;
})}`;
// clang-format on
}
#renderIframe(iframeId: string): Lit.TemplateResult|null {
const domLoadingId = iframeId as Protocol.Page.FrameId;
if (!domLoadingId) {
return null;
}
const domLoadingFrame = SDK.FrameManager.FrameManager.instance().getFrame(domLoadingId);
if (!domLoadingFrame) {
return null;
}
const el = LegacyComponents.Linkifier.Linkifier.linkifyRevealable(domLoadingFrame, domLoadingFrame.displayName());
// clang-format off
return html`
<span class="culprit">
<span class="culprit-type"> ${i18nString(UIStrings.injectedIframe)}: </span>
<span class="culprit-value">${el}</span>
</span>`;
// clang-format on
}
#renderFontRequest(request: Trace.Types.Events.SyntheticNetworkRequest): Lit.TemplateResult|null {
const options = {
tabStop: true,
showColumnNumber: false,
inlineFrameIndex: 0,
maxLength: MAX_URL_LENGTH,
};
const linkifiedURL = LegacyComponents.Linkifier.Linkifier.linkifyURL(
request.args.data.url as Platform.DevToolsPath.UrlString, options);
// clang-format off
return html`
<span class="culprit">
<span class="culprit-type">${i18nString(UIStrings.fontRequest)}: </span>
<span class="culprit-value">${linkifiedURL}</span>
</span>`;
// clang-format on
}
// TODO(crbug.com/368170718): use eventRef instead
#clickEvent(event: Trace.Types.Events.Event): void {
this.dispatchEvent(new Insights.EventRef.EventReferenceClick(event));
}
#renderAnimation(failure: Trace.Insights.Models.CLSCulprits.NoncompositedAnimationFailure): Lit.TemplateResult|null {
const event = failure.animation;
if (!event) {
return null;
}
// clang-format off
return html`
<span class="culprit">
<span class="culprit-type">${i18nString(UIStrings.nonCompositedAnimation)}: </span>
<button type="button" class="culprit-value timeline-link" @click=${() => this.#clickEvent(event)}>${i18nString(UIStrings.animation)}</button>
</span>`;
// clang-format on
}
#renderUnsizedImage(frame: string, backendNodeId: Protocol.DOM.BackendNodeId): Lit.TemplateResult|null {
// clang-format off
const el = html`
<devtools-performance-node-link
.data=${{
backendNodeId,
frame,
// TODO(crbug.com/371620361): if ever rendered for non-fresh traces, this needs to set a fallback text value. This requires
// `rootCauses.unsizedImages` to have more than just the backend node id.
} as Insights.NodeLink.NodeLinkData}>
</devtools-performance-node-link>`;
return html`
<span class="culprit">
<span class="culprit-type">${i18nString(UIStrings.unsizedImage)}: </span>
<span class="culprit-value">${el}</span>
</span>`;
// clang-format on
}
#renderRootCauseValues(
frame: string,
rootCauses: Trace.Insights.Models.CLSCulprits.LayoutShiftRootCausesData|undefined): Lit.TemplateResult|null {
return html`
${rootCauses?.fontRequests.map(fontReq => this.#renderFontRequest(fontReq))}
${rootCauses?.iframeIds.map(iframe => this.#renderIframe(iframe))}
${rootCauses?.nonCompositedAnimations.map(failure => this.#renderAnimation(failure))}
${rootCauses?.unsizedImages.map(unsizedImage => this.#renderUnsizedImage(frame, unsizedImage.backendNodeId))}
`;
}
#renderStartTime(shift: Trace.Types.Events.SyntheticLayoutShift, parsedTrace: Trace.Handlers.Types.ParsedTrace):
Lit.TemplateResult|null {
const ts = Trace.Types.Timing.Micro(shift.ts - parsedTrace.Meta.traceBounds.min);
if (shift === this.#event) {
return html`${i18n.TimeUtilities.preciseMillisToString(Helpers.Timing.microToMilli(ts))}`;
}
const shiftTs = i18n.TimeUtilities.formatMicroSecondsTime(ts);
// clang-format off
return html`
<button type="button" class="timeline-link" @click=${() => this.#clickEvent(shift)}>${i18nString(UIStrings.layoutShift, {PH1: shiftTs})}</button>`;
// clang-format on
}
#renderShiftRow(
shift: Trace.Types.Events.SyntheticLayoutShift, parsedTrace: Trace.Handlers.Types.ParsedTrace,
elementsShifted: Trace.Types.Events.TraceImpactedNode[],
rootCauses: Trace.Insights.Models.CLSCulprits.LayoutShiftRootCausesData|undefined): Lit.TemplateResult|null {
const score = shift.args.data?.weighted_score_delta;
if (!score) {
return null;
}
const hasCulprits = Boolean(
rootCauses &&
(rootCauses.fontRequests.length || rootCauses.iframeIds.length || rootCauses.nonCompositedAnimations.length ||
rootCauses.unsizedImages.length));
// TODO(crbug.com/371620361): Needs to show something for non-fresh recordings.
// clang-format off
return html`
<tr class="shift-row" data-ts=${shift.ts}>
<td>${this.#renderStartTime(shift, parsedTrace)}</td>
<td>${(score.toFixed(4))}</td>
${this.#isFreshRecording ? html`
<td>
<div class="elements-shifted">
${this.#renderShiftedElements(shift, elementsShifted)}
</div>
</td>` : Lit.nothing}
${hasCulprits && this.#isFreshRecording ? html`
<td class="culprits">
${this.#renderRootCauseValues(shift.args.frame, rootCauses)}
</td>` : Lit.nothing}
</tr>`;
// clang-format on
}
#renderParentCluster(
cluster: Trace.Types.Events.SyntheticLayoutShiftCluster|undefined,
parsedTrace: Trace.Handlers.Types.ParsedTrace): Lit.TemplateResult|null {
if (!cluster) {
return null;
}
const ts = Trace.Types.Timing.Micro(cluster.ts - (parsedTrace?.Meta.traceBounds.min ?? 0));
const clusterTs = i18n.TimeUtilities.formatMicroSecondsTime(ts);
// clang-format off
return html`
<span class="parent-cluster">${i18nString(UIStrings.parentCluster)}:
<button type="button" class="timeline-link" @click=${() => this.#clickEvent(cluster)}>${i18nString(UIStrings.cluster, {PH1: clusterTs})}</button>
</span>`;
// clang-format on
}
#renderClusterTotalRow(cluster: Trace.Types.Events.SyntheticLayoutShiftCluster): Lit.TemplateResult|null {
// clang-format off
return html`
<td class="total-row">${i18nString(UIStrings.total)}</td>
<td class="total-row">${(cluster.clusterCumulativeScore.toFixed(4))}</td>`;
// clang-format on
}
#renderShiftDetails(
layoutShift: Trace.Types.Events.SyntheticLayoutShift,
traceInsightsSets: Trace.Insights.Types.TraceInsightSets|null,
parsedTrace: Trace.Handlers.Types.ParsedTrace,
): Lit.TemplateResult|null {
if (!traceInsightsSets) {
return null;
}
const insightsId = layoutShift.args.data?.navigationId ?? Trace.Types.Events.NO_NAVIGATION;
const clsInsight = traceInsightsSets.get(insightsId)?.model.CLSCulprits;
if (!clsInsight || clsInsight instanceof Error) {
return null;
}
const rootCauses = clsInsight.shifts.get(layoutShift);
const elementsShifted = layoutShift.args.data?.impacted_nodes ?? [];
const hasCulprits = rootCauses &&
(rootCauses.fontRequests.length || rootCauses.iframeIds.length || rootCauses.nonCompositedAnimations.length ||
rootCauses.unsizedImages.length);
const hasShiftedElements = elementsShifted?.length;
const parentCluster = clsInsight.clusters.find(cluster => {
return cluster.events.find(event => event === layoutShift);
});
// clang-format off
return html`
<table class="layout-shift-details-table">
<thead class="table-title">
<tr>
<th>${i18nString(UIStrings.startTime)}</th>
<th>${i18nString(UIStrings.shiftScore)}</th>
${hasShiftedElements && this.#isFreshRecording ? html`
<th>${i18nString(UIStrings.elementsShifted)}</th>` : Lit.nothing}
${hasCulprits && this.#isFreshRecording ? html`
<th>${i18nString(UIStrings.culprit)}</th> ` : Lit.nothing}
</tr>
</thead>
<tbody>
${this.#renderShiftRow(layoutShift, parsedTrace, elementsShifted, rootCauses)}
</tbody>
</table>
${this.#renderParentCluster(parentCluster, parsedTrace)}
`;
// clang-format on
}
#renderClusterDetails(
cluster: Trace.Types.Events.SyntheticLayoutShiftCluster,
traceInsightsSets: Trace.Insights.Types.TraceInsightSets|null,
parsedTrace: Trace.Handlers.Types.ParsedTrace): Lit.TemplateResult|null {
if (!traceInsightsSets) {
return null;
}
const insightsId = cluster.navigationId ?? Trace.Types.Events.NO_NAVIGATION;
const clsInsight = traceInsightsSets.get(insightsId)?.model.CLSCulprits;
if (!clsInsight || clsInsight instanceof Error) {
return null;
}
// This finds the culprits of the cluster and returns an array of the culprits.
const clusterCulprits = Array.from(clsInsight.shifts.entries())
.filter(([key]) => cluster.events.includes(key))
.map(([, value]) => value)
.flatMap(x => Object.values(x))
.flat();
const hasCulprits = Boolean(clusterCulprits.length);
// clang-format off
return html`
<table class="layout-shift-details-table">
<thead class="table-title">
<tr>
<th>${i18nString(UIStrings.startTime)}</th>
<th>${i18nString(UIStrings.shiftScore)}</th>
${this.#isFreshRecording ? html`
<th>${i18nString(UIStrings.elementsShifted)}</th>` : Lit.nothing}
${hasCulprits && this.#isFreshRecording ? html`
<th>${i18nString(UIStrings.culprit)}</th> ` : Lit.nothing}
</tr>
</thead>
<tbody>
${cluster.events.map(shift => {
const rootCauses = clsInsight.shifts.get(shift);
const elementsShifted = shift.args.data?.impacted_nodes ?? [];
return this.#renderShiftRow(shift, parsedTrace, elementsShifted, rootCauses);
})}
${this.#renderClusterTotalRow(cluster)}
</tbody>
</table>
`;
// clang-format on
}
#render(): void {
if (!this.#event || !this.#parsedTrace) {
return;
}
// clang-format off
const output = html`
<style>${layoutShiftDetailsStyles}</style>
<style>${Buttons.textButtonStyles}</style>
<div class="layout-shift-summary-details">
<div
class="event-details"
@mouseover=${this.#togglePopover}
@mouseleave=${this.#togglePopover}
>
${this.#renderTitle(this.#event)}
${Trace.Types.Events.isSyntheticLayoutShift(this.#event)
? this.#renderShiftDetails(this.#event, this.#traceInsightsSets, this.#parsedTrace)
: this.#renderClusterDetails(this.#event, this.#traceInsightsSets, this.#parsedTrace)
}
</div>
</div>
`;
// clang-format on
Lit.render(output, this.#shadow, {host: this});
}
#togglePopover(e: MouseEvent): void {
const show = e.type === 'mouseover';
if (e.type === 'mouseleave') {
this.dispatchEvent(new CustomEvent('toggle-popover', {detail: {show}, bubbles: true, composed: true}));
}
if (!(e.target instanceof HTMLElement) || !this.#event) {
return;
}
const rowEl = e.target.closest('tbody tr');
if (!rowEl?.parentElement) {
return;
}
// Grab the associated trace event of this row.
const event = Trace.Types.Events.isSyntheticLayoutShift(this.#event) ?
this.#event :
this.#event.events.find(e => e.ts === parseInt(rowEl.getAttribute('data-ts') ?? '', 10));
this.dispatchEvent(new CustomEvent('toggle-popover', {detail: {event, show}, bubbles: true, composed: true}));
}
}
declare global {
interface HTMLElementTagNameMap {
'devtools-performance-layout-shift-details': LayoutShiftDetails;
}
}
customElements.define('devtools-performance-layout-shift-details', LayoutShiftDetails);