From 2d9e17918878525fc76bc88687f601001217e2a6 Mon Sep 17 00:00:00 2001 From: hawkgs Date: Tue, 2 Dec 2025 16:41:48 +0200 Subject: [PATCH] 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. --- .../injector-tree/injector-tree-fns.spec.ts | 133 ++++++++++++++++++ .../injector-tree/injector-tree-fns.ts | 48 +++++++ .../injector-tree/injector-tree.component.ts | 9 +- 3 files changed, 188 insertions(+), 2 deletions(-) diff --git a/devtools/projects/ng-devtools/src/lib/devtools-tabs/injector-tree/injector-tree-fns.spec.ts b/devtools/projects/ng-devtools/src/lib/devtools-tabs/injector-tree/injector-tree-fns.spec.ts index 93797858d4f..11f728add47 100644 --- a/devtools/projects/ng-devtools/src/lib/devtools-tabs/injector-tree/injector-tree-fns.spec.ts +++ b/devtools/projects/ng-devtools/src/lib/devtools-tabs/injector-tree/injector-tree-fns.spec.ts @@ -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); + }); +}); diff --git a/devtools/projects/ng-devtools/src/lib/devtools-tabs/injector-tree/injector-tree-fns.ts b/devtools/projects/ng-devtools/src/lib/devtools-tabs/injector-tree/injector-tree-fns.ts index 3ccd4bbd9f9..6f867d2cbbb 100644 --- a/devtools/projects/ng-devtools/src/lib/devtools-tabs/injector-tree/injector-tree-fns.ts +++ b/devtools/projects/ng-devtools/src/lib/devtools-tabs/injector-tree/injector-tree-fns.ts @@ -296,3 +296,51 @@ export function d3InjectorTreeNodeModifier(d3Node: SvgD3Node) 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; +} diff --git a/devtools/projects/ng-devtools/src/lib/devtools-tabs/injector-tree/injector-tree.component.ts b/devtools/projects/ng-devtools/src/lib/devtools-tabs/injector-tree/injector-tree.component.ts index 0ca49b91d56..007167a87c7 100644 --- a/devtools/projects/ng-devtools/src/lib/devtools-tabs/injector-tree/injector-tree.component.ts +++ b/devtools/projects/ng-devtools/src/lib/devtools-tabs/injector-tree/injector-tree.component.ts @@ -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(null); - protected readonly environmentInjectorTree = signal(null); + protected readonly elementInjectorTree = signal(null, { + equal: areInjectorTreesEqual, + }); + protected readonly environmentInjectorTree = signal(null, { + equal: areInjectorTreesEqual, + }); protected readonly responsiveSplitConfig: ResponsiveSplitConfig = { defaultDirection: 'vertical',