chrome-devtools-frontend
Version:
Chrome DevTools UI
285 lines (244 loc) • 10 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 * as WebVitals from '../../../third_party/web-vitals/web-vitals.js';
import type * as Trace from '../../trace/trace.js';
import * as OnEachLayoutShift from './OnEachLayoutShift.js';
import * as Spec from './spec/spec.js';
const {onLCP, onCLS, onINP} = WebVitals.Attribution;
const {onEachLayoutShift} = OnEachLayoutShift;
declare const window: Window&{
getNodeForIndex: (index: number) => Node | undefined,
[Spec.INTERNAL_KILL_SWITCH]: () => void,
[Spec.EVENT_BINDING_NAME]: (payload: string) => void,
devToolsReportSoftNavs?: boolean,
};
const eventListenerCleanupController = new AbortController();
const patchAddListener = (proto: typeof Window.prototype|typeof Document.prototype): void => {
const original = proto.addEventListener;
proto.addEventListener = function(
type: string, listener: EventListenerOrEventListenerObject, options?: boolean|AddEventListenerOptions): void {
// Standardize options into an object
const navOptions = typeof options === 'boolean' ? {capture: options} : {...options};
// If we already have a signal, we should respect it,
// but also link it to our global cleanup signal.
if (navOptions.signal) {
navOptions.signal = AbortSignal.any([navOptions.signal, eventListenerCleanupController.signal]);
} else {
navOptions.signal = eventListenerCleanupController.signal;
}
return original.call(this, type, listener, navOptions);
};
};
// Patch the core targets
patchAddListener(Window.prototype);
patchAddListener(Document.prototype);
// Use a class wrapper that auto-registers and auto-unregisters
const activeObservers = new Set<PerformanceObserver>();
class TrackedPerformanceObserver extends globalThis.PerformanceObserver {
constructor(callback: PerformanceObserverCallback) {
super(callback);
activeObservers.add(this);
}
// Override disconnect to remove it from our tracking set
override disconnect(): void {
super.disconnect();
activeObservers.delete(this);
}
}
const nodeList: Array<WeakRef<Node>> = [];
const nodeToIdMap = new WeakMap<Node, number>();
function establishNodeIndex(node: Node): number {
let index = nodeToIdMap.get(node);
if (index !== undefined) {
return index;
}
index = nodeList.length;
nodeList.push(new WeakRef(node));
nodeToIdMap.set(node, index);
return index;
}
// Replace the global constructor
globalThis.PerformanceObserver = TrackedPerformanceObserver;
/**
* This is a hack solution to remove any listeners that were added by web-vitals.js
* or additional services in this bundle. Once this function is called, the execution
* context should be considered dead and a new one will need to be created for live metrics
* to be served again.
*/
let killed = false;
window[Spec.INTERNAL_KILL_SWITCH] = () => {
if (killed) {
return;
}
for (const observer of activeObservers) {
// This calls the overridden disconnect above,
// cleaning up BOTH the browser resource and our Set.
observer.disconnect();
}
activeObservers.clear();
eventListenerCleanupController.abort();
// Explicitly clear the Node List to help GC
nodeList.length = 0;
killed = true;
};
function sendEventToDevTools(event: Spec.WebVitalsEvent): void {
const payload = JSON.stringify(event);
window[Spec.EVENT_BINDING_NAME]?.(payload);
}
/**
* The data sent over the event binding needs to be JSON serializable, so we
* can't send DOM nodes directly. Instead we create an ID for each node (see
* `establishNodeIndex`) that we can later use to retrieve a remote object
* for that node.
*
* This function is used by `Runtime.evaluate` calls to get a remote object
* for the specified index.
*/
window.getNodeForIndex = (index: number): Node|undefined => {
return nodeList[index].deref();
};
function limitScripts(loafs: Spec.PerformanceLongAnimationFrameTimingJSON[]):
Spec.PerformanceLongAnimationFrameTimingJSON[] {
return loafs.map(loaf => {
const longestScripts: Spec.PerformanceScriptTimingJSON[] = [];
for (const script of loaf.scripts) {
if (longestScripts.length < Spec.SCRIPTS_PER_LOAF_LIMIT) {
longestScripts.push(script);
continue;
}
const shorterIndex = longestScripts.findIndex(s => s.duration < script.duration);
if (shorterIndex === -1) {
continue;
}
longestScripts[shorterIndex] = script;
}
longestScripts.sort((a, b) => a.startTime - b.startTime);
loaf.scripts = longestScripts;
return loaf;
});
}
function isPrerendered(): boolean {
if (document.prerendering) {
return true;
}
const firstNavStart = self.performance.getEntriesByType?.('navigation')[0]?.activationStart;
return firstNavStart !== undefined && firstNavStart > 0;
}
let startedHidden: boolean|null = null;
function initialize(): void {
sendEventToDevTools({name: 'reset'});
new PerformanceObserver(list => {
for (const entry of list.getEntries()) {
if (startedHidden === null && !isPrerendered()) {
startedHidden = entry.name === 'hidden';
}
}
}).observe({type: 'visibility-state', buffered: true});
// We want to treat bfcache navigations like a standard navigations, so emit
// a reset event when bfcache is restored.
//
// Metric functions will also re-emit their values using this listener's callback.
// To ensure this event is fired before those values are emitted, register this
// callback before any others.
WebVitals.onBFCacheRestore(() => {
startedHidden = false;
sendEventToDevTools({name: 'reset', navigationType: 'back-forward-cache'});
});
let lastLcpNavigationId: number|undefined;
onLCP(metric => {
if (lastLcpNavigationId && metric.navigationId && metric.navigationId !== lastLcpNavigationId) {
sendEventToDevTools({name: 'reset', url: window.location.href, navigationType: metric.navigationType});
}
lastLcpNavigationId = metric.navigationId;
const event: Spec.LcpChangeEvent = {
name: 'LCP',
value: metric.value as Trace.Types.Timing.Milli,
startedHidden: Boolean(startedHidden),
subparts: {
timeToFirstByte: metric.attribution.timeToFirstByte as Trace.Types.Timing.Milli,
resourceLoadDelay: metric.attribution.resourceLoadDelay as Trace.Types.Timing.Milli,
resourceLoadTime: metric.attribution.resourceLoadDuration as Trace.Types.Timing.Milli,
elementRenderDelay: metric.attribution.elementRenderDelay as Trace.Types.Timing.Milli,
},
};
const element = metric.attribution.lcpEntry?.element;
if (element) {
event.nodeIndex = establishNodeIndex(element);
}
sendEventToDevTools(event);
}, {reportAllChanges: true, reportSoftNavs: window.devToolsReportSoftNavs});
onCLS(metric => {
const event: Spec.ClsChangeEvent = {
name: 'CLS',
value: metric.value,
clusterShiftIds: metric.entries.map(Spec.getUniqueLayoutShiftId),
};
sendEventToDevTools(event);
}, {reportAllChanges: true, reportSoftNavs: window.devToolsReportSoftNavs});
function onEachInteraction(interaction: WebVitals.INPMetricWithAttribution): void {
// Multiple `InteractionEntry` events can be emitted for the same `uniqueInteractionId`
// However, it is easier to combine these entries in the DevTools client rather than in
// this injected code.
const event: Spec.InteractionEntryEvent = {
name: 'InteractionEntry',
duration: interaction.value as Trace.Types.Timing.Milli,
subparts: {
inputDelay: interaction.attribution.inputDelay as Trace.Types.Timing.Milli,
processingDuration: interaction.attribution.processingDuration as Trace.Types.Timing.Milli,
presentationDelay: interaction.attribution.presentationDelay as Trace.Types.Timing.Milli,
},
startTime: interaction.entries[0].startTime,
navigationId: interaction.navigationId,
entryGroupId: interaction.entries[0].interactionId as Spec.InteractionEntryGroupId,
nextPaintTime: interaction.attribution.nextPaintTime,
interactionType: interaction.attribution.interactionType,
eventName: interaction.entries[0].name,
// To limit the amount of events, just get the last 5 LoAFs
longAnimationFrameEntries: limitScripts(
interaction.attribution.longAnimationFrameEntries.slice(-Spec.LOAF_LIMIT).map(loaf => loaf.toJSON())),
};
const target = interaction.attribution.interactionTarget;
if (target) {
event.nodeIndex = Number(target);
}
sendEventToDevTools(event);
}
onINP(metric => {
const event: Spec.InpChangeEvent = {
name: 'INP',
value: metric.value as Trace.Types.Timing.Milli,
subparts: {
inputDelay: metric.attribution.inputDelay as Trace.Types.Timing.Milli,
processingDuration: metric.attribution.processingDuration as Trace.Types.Timing.Milli,
presentationDelay: metric.attribution.presentationDelay as Trace.Types.Timing.Milli,
},
startTime: metric.entries[0].startTime,
entryGroupId: metric.entries[0].interactionId as Spec.InteractionEntryGroupId,
interactionType: metric.attribution.interactionType,
};
sendEventToDevTools(event);
}, {
reportAllChanges: true,
durationThreshold: 0,
includeProcessedEventEntries: false,
reportSoftNavs: window.devToolsReportSoftNavs,
onEachInteraction,
generateTarget(el) {
if (el) {
return String(establishNodeIndex(el));
}
return undefined;
},
});
onEachLayoutShift(layoutShift => {
const event: Spec.LayoutShiftEvent = {
name: 'LayoutShift',
score: layoutShift.value,
uniqueLayoutShiftId: Spec.getUniqueLayoutShiftId(layoutShift.entry),
affectedNodeIndices: layoutShift.attribution.affectedNodes.map(establishNodeIndex),
};
sendEventToDevTools(event);
});
}
initialize();