angular/devtools/projects/ng-devtools-backend/src/lib/directive-forest/render-tree.ts
AleksanderBodurri 5115050928 fix(devtools): DOM traversal bug (#62719)
Previously, Angular devtools would mistakenly traverse the same DOM elements multiple times while doing traversal for the component tree explorer. This error case would occur when more than 1 Angular application root component was present on the same page and in distinct DOM branches.

Some example cases that did work previously:

```html
<app-root>
...
</app-root>
```

```html
<app-root>
...
<app-root-2></app-root-2>
...
</app-root>
```

An example of where it would enter the irregular behaviour

```html
<app-root>
...
</app-root>
<app-root-2>
...
</app-root-2>
```

Now, we properly ignore duplicate DOM paths when looking for application and non-application root component to begin the Angular DevTools component discovery logic.

PR Close #62719
2025-08-18 15:43:09 +00:00

259 lines
7.1 KiB
TypeScript

/**
* @license
* Copyright Google LLC All Rights Reserved.
*
* Use of this source code is governed by an MIT-style license that can be
* found in the LICENSE file at https://angular.dev/license
*/
import {
ɵFrameworkAgnosticGlobalUtils as FrameworkAgnosticGlobalUtils,
ɵDeferBlockData as DeferBlockData,
ɵHydratedNode as HydrationNode,
} from '@angular/core';
import {CurrentDeferBlock, HydrationStatus} from '../../../../protocol';
import {ComponentTreeNode} from '../interfaces';
import {ngDebugClient} from '../ng-debug-api/ng-debug-api';
import {isCustomElement} from '../utils';
const extractViewTree = (
domNode: Node | Element,
result: ComponentTreeNode[],
deferBlocks: DeferBlocksIterator,
rootId: number,
getComponent?: FrameworkAgnosticGlobalUtils['getComponent'],
getDirectives?: FrameworkAgnosticGlobalUtils['getDirectives'],
getDirectiveMetadata?: FrameworkAgnosticGlobalUtils['getDirectiveMetadata'],
): ComponentTreeNode[] => {
// Ignore DOM Node if it came from a different frame. Use instanceof Node to check this.
if (!(domNode instanceof Node)) {
return result;
}
const directives = getDirectives?.(domNode) ?? [];
if (!directives.length && !(domNode instanceof Element)) {
return result;
}
const componentTreeNode: ComponentTreeNode = {
children: [],
component: null,
directives: directives.map((dir) => {
return {
instance: dir,
name: dir.constructor.name,
};
}),
element: domNode.nodeName.toLowerCase(),
nativeElement: domNode,
hydration: hydrationStatus(domNode),
defer: null,
};
if (!(domNode instanceof Element)) {
// In case we show the Comment nodes
result.push(componentTreeNode);
return result;
}
const isDehydratedElement = componentTreeNode.hydration?.status === 'dehydrated';
const component = getComponent?.(domNode);
if (component) {
componentTreeNode.component = {
instance: component,
isElement: isCustomElement(domNode),
name: getDirectiveMetadata?.(component)?.name ?? domNode.nodeName.toLowerCase(),
};
}
const isDisplayableNode = component || componentTreeNode.directives.length || isDehydratedElement;
if (isDisplayableNode) {
result.push(componentTreeNode);
}
// Nodes that are part of a defer block will be added as children of the defer block
// and should be skipped from the regular code path
const deferredNodesToSkip = new Set<Node>();
const appendTo = isDisplayableNode ? componentTreeNode.children : result;
domNode.childNodes.forEach((node) => {
groupDeferChildrenIfNeeded(
node,
deferredNodesToSkip,
appendTo,
deferBlocks,
rootId,
getComponent,
getDirectives,
getDirectiveMetadata,
);
if (!deferredNodesToSkip.has(node)) {
extractViewTree(
node,
appendTo,
deferBlocks,
rootId,
getComponent,
getDirectives,
getDirectiveMetadata,
);
}
});
return result;
};
/**
* Group Nodes under a defer block if they are part of it.
*
* @param node
* @param deferredNodesToSkip Will mutate the set with the nodes that are grouped into the created deferblock.
* @param deferBlocks
* @param appendTo
* @param getComponent
* @param getDirectives
* @param getDirectiveMetadata
*/
function groupDeferChildrenIfNeeded(
node: Node,
deferredNodesToSkip: Set<Node>,
appendTo: ComponentTreeNode[],
deferBlocks: DeferBlocksIterator,
rootId: number,
getComponent?: FrameworkAgnosticGlobalUtils['getComponent'],
getDirectives?: FrameworkAgnosticGlobalUtils['getDirectives'],
getDirectiveMetadata?: FrameworkAgnosticGlobalUtils['getDirectiveMetadata'],
) {
const currentDeferBlock = deferBlocks.currentBlock;
const isFirstDefferedChild = node === currentDeferBlock?.rootNodes[0];
if (isFirstDefferedChild) {
deferBlocks.advance();
// When encountering the first child of a defer block
// We create a synthetic TreeNode reprensenting the defer block
const childrenTree: ComponentTreeNode[] = [];
currentDeferBlock.rootNodes.forEach((child) => {
extractViewTree(
child,
childrenTree,
deferBlocks,
rootId,
getComponent,
getDirectives,
getDirectiveMetadata,
);
});
const deferBlockTreeNode = {
children: childrenTree,
component: null,
directives: [],
element: '@defer',
nativeElement: undefined,
hydration: null,
defer: {
id: `deferId-${rootId}-${deferBlocks.currentIndex}`,
state: currentDeferBlock.state,
currentBlock: currentBlock(currentDeferBlock),
triggers: groupTriggers(currentDeferBlock.triggers),
blocks: {
hasErrorBlock: currentDeferBlock.hasErrorBlock,
placeholderBlock: currentDeferBlock.placeholderBlock,
loadingBlock: currentDeferBlock.loadingBlock,
},
},
} satisfies ComponentTreeNode;
currentDeferBlock?.rootNodes.forEach((child) => deferredNodesToSkip.add(child));
appendTo.push(deferBlockTreeNode);
}
}
function hydrationStatus(element: Node): HydrationStatus {
if (!(element instanceof Element)) {
return null;
}
if (!!element.getAttribute('ngh')) {
return {status: 'dehydrated'};
}
const hydrationInfo = (element as HydrationNode).__ngDebugHydrationInfo__;
switch (hydrationInfo?.status) {
case 'hydrated':
return {status: 'hydrated'};
case 'skipped':
return {status: 'skipped'};
case 'mismatched':
return {
status: 'mismatched',
expectedNodeDetails: hydrationInfo.expectedNodeDetails,
actualNodeDetails: hydrationInfo.actualNodeDetails,
};
default:
return null;
}
}
function groupTriggers(triggers: string[]) {
const defer: string[] = [];
const hydrate: string[] = [];
const prefetch: string[] = [];
for (let trigger of triggers) {
if (trigger.startsWith('hydrate')) {
hydrate.push(trigger);
} else if (trigger.startsWith('prefetch')) {
prefetch.push(trigger);
} else {
defer.push(trigger);
}
}
return {defer, hydrate, prefetch};
}
function currentBlock(deferBlock: DeferBlockData): CurrentDeferBlock | null {
if (['placeholder', 'loading', 'error'].includes(deferBlock.state)) {
return deferBlock.state as 'placeholder' | 'loading' | 'error';
}
return null;
}
export class RTreeStrategy {
supports(): boolean {
return (['getDirectiveMetadata', 'getComponent'] as const).every(
(method) => typeof ngDebugClient()[method] === 'function',
);
}
build(element: Element, rootId: number = 0): ComponentTreeNode[] {
const ng = ngDebugClient();
const deferBlocks = ng.ɵgetDeferBlocks?.(element) ?? [];
return extractViewTree(
element,
[],
new DeferBlocksIterator(deferBlocks),
rootId,
ng.getComponent,
ng.getDirectives,
ng.getDirectiveMetadata,
);
}
}
class DeferBlocksIterator {
public currentIndex = 0;
private blocks: DeferBlockData[] = [];
constructor(blocks: DeferBlockData[]) {
this.blocks = blocks;
}
advance() {
this.currentIndex++;
}
get currentBlock() {
return this.blocks[this.currentIndex];
}
}