chrome-devtools-frontend
Version:
Chrome DevTools UI
346 lines (291 loc) • 10.8 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.
import * as Core from '../core/core.js';
import type * as Lantern from '../types/types.js';
import type {CPUNode} from './CPUNode.js';
import type {NetworkNode} from './NetworkNode.js';
/**
* A union of all types derived from BaseNode, allowing type check discrimination
* based on `node.type`. If a new node type is created, it should be added here.
*/
export type Node<T = Lantern.AnyNetworkObject> = CPUNode<T>|NetworkNode<T>;
/**
* @fileoverview This class encapsulates logic for handling resources and tasks used to model the
* execution dependency graph of the page. A node has a unique identifier and can depend on other
* nodes/be depended on. The construction of the graph maintains some important invariants that are
* inherent to the model:
*
* 1. The graph is a DAG, there are no cycles.
* 2. There is always a root node upon which all other nodes eventually depend.
*
* This allows particular optimizations in this class so that we do no need to check for cycles as
* these methods are called and we can always start traversal at the root node.
*/
class BaseNode<T = Lantern.AnyNetworkObject> {
static types = {
NETWORK: 'network',
CPU: 'cpu',
} as const;
_id: string;
_isMainDocument: boolean;
dependents: Node[];
dependencies: Node[];
constructor(id: string) {
this._id = id;
this._isMainDocument = false;
this.dependents = [];
this.dependencies = [];
}
get id(): string {
return this._id;
}
get type(): 'network'|'cpu' {
throw new Core.LanternError('Unimplemented');
}
/**
* In microseconds
*/
get startTime(): number {
throw new Core.LanternError('Unimplemented');
}
/**
* In microseconds
*/
get endTime(): number {
throw new Core.LanternError('Unimplemented');
}
setIsMainDocument(value: boolean): void {
this._isMainDocument = value;
}
isMainDocument(): boolean {
return this._isMainDocument;
}
getDependents(): Node[] {
return this.dependents.slice();
}
getNumberOfDependents(): number {
return this.dependents.length;
}
getDependencies(): Node[] {
return this.dependencies.slice();
}
getNumberOfDependencies(): number {
return this.dependencies.length;
}
getRootNode(): Node<T> {
let rootNode = this as BaseNode as Node;
while (rootNode.dependencies.length) {
rootNode = rootNode.dependencies[0];
}
return rootNode;
}
addDependent(node: Node): void {
node.addDependency(this as BaseNode as Node);
}
addDependency(node: Node): void {
// @ts-expect-error - in checkJs, ts doesn't know that CPUNode and NetworkNode *are* BaseNodes.
if (node === this) {
throw new Core.LanternError('Cannot add dependency on itself');
}
if (this.dependencies.includes(node)) {
return;
}
node.dependents.push(this as BaseNode as Node);
this.dependencies.push(node);
}
removeDependent(node: Node): void {
node.removeDependency(this as BaseNode as Node);
}
removeDependency(node: Node): void {
if (!this.dependencies.includes(node)) {
return;
}
const thisIndex = node.dependents.indexOf(this as BaseNode as Node);
node.dependents.splice(thisIndex, 1);
this.dependencies.splice(this.dependencies.indexOf(node), 1);
}
// Unused in devtools, but used in LH.
removeAllDependencies(): void {
for (const node of this.dependencies.slice()) {
this.removeDependency(node);
}
}
/**
* Computes whether the given node is anywhere in the dependency graph of this node.
* While this method can prevent cycles, it walks the graph and should be used sparingly.
* Nodes are always considered dependent on themselves for the purposes of cycle detection.
*/
isDependentOn(node: BaseNode<T>): boolean {
let isDependentOnNode = false;
this.traverse(
currentNode => {
if (isDependentOnNode) {
return;
}
isDependentOnNode = currentNode === node;
},
currentNode => {
// If we've already found the dependency, don't traverse further.
if (isDependentOnNode) {
return [];
}
// Otherwise, traverse the dependencies.
return currentNode.getDependencies();
});
return isDependentOnNode;
}
/**
* Clones the node's information without adding any dependencies/dependents.
*/
cloneWithoutRelationships(): Node<T> {
const node = new BaseNode(this.id) as Node<T>;
node.setIsMainDocument(this._isMainDocument);
return node;
}
/**
* Clones the entire graph connected to this node filtered by the optional predicate. If a node is
* included by the predicate, all nodes along the paths between the node and the root will be included. If the
* node this was called on is not included in the resulting filtered graph, the method will throw.
*
* This does not clone NetworkNode's `record` or `rawRecord` fields. It may be reasonable to clone the former,
* to assist in graph construction, but the latter should never be cloned as one constraint of Lantern is that
* the underlying data records are accessible for plain object reference equality checks.
*/
cloneWithRelationships(predicate?: (arg0: Node) => boolean): Node {
const rootNode = this.getRootNode();
const idsToIncludedClones = new Map<string, Node>();
// Walk down dependents.
rootNode.traverse(node => {
if (idsToIncludedClones.has(node.id)) {
return;
}
if (predicate === undefined) {
// No condition for entry, so clone every node.
idsToIncludedClones.set(node.id, node.cloneWithoutRelationships());
return;
}
if (predicate(node)) {
// Node included, so walk back up dependencies, cloning nodes from here back to the root.
node.traverse(
node => idsToIncludedClones.set(node.id, node.cloneWithoutRelationships()),
// Dependencies already cloned have already cloned ancestors, so no need to visit again.
node => node.dependencies.filter(parent => !idsToIncludedClones.has(parent.id)),
);
}
});
// Copy dependencies between nodes.
rootNode.traverse(originalNode => {
const clonedNode = idsToIncludedClones.get(originalNode.id);
if (!clonedNode) {
return;
}
for (const dependency of originalNode.dependencies) {
const clonedDependency = idsToIncludedClones.get(dependency.id);
if (!clonedDependency) {
throw new Core.LanternError('Dependency somehow not cloned');
}
clonedNode.addDependency(clonedDependency);
}
});
const clonedThisNode = idsToIncludedClones.get(this.id);
if (!clonedThisNode) {
throw new Core.LanternError('Cloned graph missing node');
}
return clonedThisNode;
}
/**
* Traverses all connected nodes in BFS order, calling `callback` exactly once
* on each. `traversalPath` is the shortest (though not necessarily unique)
* path from `node` to the root of the iteration.
*
* The `getNextNodes` function takes a visited node and returns which nodes to
* visit next. It defaults to returning the node's dependents.
*/
traverse(
callback: (node: Node<T>, traversalPath: Array<Node<T>>) => void,
getNextNodes?: (arg0: Node<T>) => Array<Node<T>>): void {
for (const {node, traversalPath} of this.traverseGenerator(getNextNodes)) {
callback(node, traversalPath);
}
}
/**
* @see BaseNode.traverse
*/
// clang-format off
*traverseGenerator(getNextNodes?: (arg0: Node) => Node[]):
Generator<{node: Node, traversalPath: Node[]}, void, unknown> {
// clang-format on
if (!getNextNodes) {
getNextNodes = node => node.getDependents();
}
// @ts-expect-error - only traverses graphs of Node, so force tsc to treat `this` as one
const queue: Node[][] = [[this]];
const visited = new Set([this.id]);
while (queue.length) {
// @ts-expect-error - queue has length so it's guaranteed to have an item
const traversalPath: Node[] = queue.shift();
const node = traversalPath[0];
yield {node, traversalPath};
for (const nextNode of getNextNodes(node)) {
if (visited.has(nextNode.id)) {
continue;
}
visited.add(nextNode.id);
queue.push([nextNode, ...traversalPath]);
}
}
}
/**
* If the given node has a cycle, returns a path representing that cycle.
* Else returns null.
*
* Does a DFS on in its dependent graph.
*/
static findCycle(node: Node, direction: 'dependents'|'dependencies'|'both' = 'both'): BaseNode[]|null {
// Checking 'both' is the default entrypoint to recursively check both directions
if (direction === 'both') {
return BaseNode.findCycle(node, 'dependents') || BaseNode.findCycle(node, 'dependencies');
}
const visited = new Set();
const currentPath: BaseNode[] = [];
const toVisit = [node];
const depthAdded = new Map([[node, 0]]);
// Keep going while we have nodes to visit in the stack
while (toVisit.length) {
// Get the last node in the stack (DFS uses stack, not queue)
// @ts-expect-error - toVisit has length so it's guaranteed to have an item
const currentNode: BaseNode = toVisit.pop();
// We've hit a cycle if the node we're visiting is in our current dependency path
if (currentPath.includes(currentNode)) {
return currentPath;
}
// If we've already visited the node, no need to revisit it
if (visited.has(currentNode)) {
continue;
}
// Since we're visiting this node, clear out any nodes in our path that we had to backtrack
// @ts-expect-error
while (currentPath.length > depthAdded.get(currentNode)) {
currentPath.pop();
}
// Update our data structures to reflect that we're adding this node to our path
visited.add(currentNode);
currentPath.push(currentNode);
// Add all of its dependents to our toVisit stack
const nodesToExplore = direction === 'dependents' ? currentNode.dependents : currentNode.dependencies;
for (const nextNode of nodesToExplore) {
if (toVisit.includes(nextNode)) {
continue;
}
toVisit.push(nextNode);
depthAdded.set(nextNode, currentPath.length);
}
}
return null;
}
canDependOn(node: Node): boolean {
return node.startTime <= this.startTime;
}
}
export {BaseNode};