perf(devtools): improve injector tree rendering performance

Introduce an equality function for transformed injector trees in order to omit redundant D3 tree visualization render cycles when the tree hasn't been changed.
This commit is contained in:
hawkgs
2025-12-02 16:41:48 +02:00
committed by Kirill Cherkashin
parent de43806a2e
commit 2d9e179188
3 changed files with 188 additions and 2 deletions
@@ -9,6 +9,8 @@
import {DevToolsNode, SerializedInjector} from '../../../../../protocol';
import {
areInjectorTreeNodesEqual,
areInjectorTreesEqual,
equalInjector,
generateEdgeIdsFromNodeIds,
getInjectorIdsToRootFromNode,
@@ -11518,3 +11520,134 @@ describe('grabInjectorPathsFromDirectiveForest', () => {
expect(grabInjectorPathsFromDirectiveForest(directiveForest)).toEqual(expectedInjectorPaths);
});
});
describe('areInjectorTreeNodesEqual', () => {
const tree: InjectorTreeNode = {
label: 'Foo',
children: [],
injector: {
id: 'foo',
name: 'Foo',
type: 'environment',
},
};
it('should return true if the nodes are equal', () => {
const a = tree;
const b = structuredClone(tree); // Ensure that the ref is different
expect(areInjectorTreeNodesEqual(a, b)).toEqual(true);
});
it('should return true if the nodes are equal, irrespective of the children', () => {
const a = tree;
const b = structuredClone(tree); // Ensure that the ref is different
b.children.push({
label: 'Bar',
children: [],
injector: {
id: 'bar',
name: 'Bar',
type: 'element',
},
});
expect(areInjectorTreeNodesEqual(a, b)).toEqual(true);
});
it('should return false if the nodes are different', () => {
const a = tree;
const b = structuredClone(tree); // Ensure that the ref is different
b.injector.id = 'baz';
expect(areInjectorTreeNodesEqual(a, b)).toEqual(false);
});
});
describe('areInjectorTreesEqual', () => {
const tree: InjectorTreeNode = {
label: 'Foo',
injector: {
id: 'foo',
name: 'Foo',
type: 'null',
},
children: [
{
label: 'Bar',
injector: {
id: 'bar',
name: 'Bar',
type: 'environment',
},
children: [],
},
{
label: 'Baz',
injector: {
id: 'baz',
name: 'Baz',
type: 'environment',
},
children: [
{
label: 'Qux',
injector: {
id: 'qux',
name: 'Qux',
type: 'element',
},
children: [],
},
],
},
],
};
it('should return true when both null', () => {
expect(areInjectorTreesEqual(null, null)).toEqual(true);
});
it('should return false if one of the trees is null', () => {
expect(areInjectorTreesEqual(tree, null)).toEqual(false);
expect(areInjectorTreesEqual(null, tree)).toEqual(false);
});
it('should return true if the trees are equal', () => {
const a = tree;
const b = structuredClone(tree); // Ensure that the ref is different
expect(areInjectorTreesEqual(a, b)).toEqual(true);
});
it('should return false if the trees are different', () => {
const a = tree;
// Ensure that the refs are different
const b = structuredClone(tree);
const c = structuredClone(tree);
const d = structuredClone(tree);
// Update an existing node
b.children[0].label = 'Quux';
expect(areInjectorTreesEqual(a, b)).toEqual(false);
// Delete a node
c.children[1].children = [];
expect(areInjectorTreesEqual(a, c)).toEqual(false);
// Add a node
d.children[0].children.push({
label: 'Quux',
injector: {
id: 'quux',
name: 'Quux',
type: 'hidden',
},
children: [],
});
expect(areInjectorTreesEqual(a, d)).toEqual(false);
});
});
@@ -296,3 +296,51 @@ export function d3InjectorTreeNodeModifier(d3Node: SvgD3Node<InjectorTreeNode>)
return -1;
});
}
/** Returns whether InjectorTreeNodes are equal (excl. children comparison). */
export function areInjectorTreeNodesEqual(a: InjectorTreeNode, b: InjectorTreeNode): boolean {
const isSameInjector = equalInjector(a?.injector, b?.injector);
const isSameLabel = a?.label === b?.label;
const isSameSubLabel = a?.subLabel === b?.subLabel;
return isSameInjector && isSameLabel && isSameSubLabel;
}
/** Returns whether injector trees (InjectorTreeNodes with children) are equal. */
export function areInjectorTreesEqual(
a: InjectorTreeNode | null,
b: InjectorTreeNode | null,
): boolean {
if (!a && !b) {
return true;
}
if ((a && !b) || (!a && b)) {
return false;
}
const stackA: InjectorTreeNode[] = [a!];
const stackB: InjectorTreeNode[] = [b!];
while (stackA.length && stackB.length) {
const aNode = stackA.pop()!;
const bNode = stackB.pop()!;
const isDiffChildrenLength = aNode?.children.length !== bNode?.children.length;
if (!areInjectorTreeNodesEqual(aNode, bNode) || isDiffChildrenLength) {
return false;
}
if (aNode?.children && bNode?.children) {
for (let i = 0; i < aNode.children.length; i++) {
const aChild = aNode.children[i];
const bChild = bNode.children[i];
stackA.push(aChild);
stackB.push(bChild);
}
}
}
return true;
}
@@ -30,6 +30,7 @@ import {TreeD3Node, TreeVisualizerConfig} from '../../shared/tree-visualizer/tre
import {TreeVisualizerComponent} from '../../shared/tree-visualizer/tree-visualizer.component';
import {InjectorProvidersComponent} from './injector-providers/injector-providers.component';
import {
areInjectorTreesEqual,
d3InjectorTreeLinkModifier,
d3InjectorTreeNodeModifier,
filterOutAngularInjectors,
@@ -103,8 +104,12 @@ export class InjectorTreeComponent {
private hideInjectorsWithNoProviders = false;
private hideFrameworkInjectors = false;
protected readonly elementInjectorTree = signal<InjectorTreeNode | null>(null);
protected readonly environmentInjectorTree = signal<InjectorTreeNode | null>(null);
protected readonly elementInjectorTree = signal<InjectorTreeNode | null>(null, {
equal: areInjectorTreesEqual,
});
protected readonly environmentInjectorTree = signal<InjectorTreeNode | null>(null, {
equal: areInjectorTreesEqual,
});
protected readonly responsiveSplitConfig: ResponsiveSplitConfig = {
defaultDirection: 'vertical',