refactor(devtools): add static nodes for html-only block content in the component tree

Add `<html_content>` static nodes to the component tree for control flow blocks that render only HTML content.
This commit is contained in:
Georgi Serev
2026-07-11 00:27:03 +03:00
committed by GitHub
parent 4fad2c0a5d
commit f48d2769db
19 changed files with 593 additions and 13 deletions
+1
View File
@@ -8,6 +8,7 @@
module.exports = {
defaultCommandTimeout: 10000, // Increase the default command timeout to 10 seconds
viewportHeight: 2160,
e2e: {
specPattern: 'integration/*.e2e.js',
supportFile: 'support/index.js',
@@ -442,6 +442,7 @@ const prepareForestForSerialization = (
children: prepareForestForSerialization(node.children, includeResolutionPath),
hydration: node.hydration,
controlFlowBlock: node.controlFlowBlock,
static: node.static,
changeDetection: node.component ? getDirectiveCdStrategy(node.component) : undefined,
// native elements are not serializable
@@ -83,6 +83,7 @@ export function createControlFlowTreeNode(
tagName: ELEMENT_NAME_MAP[controlFlowBlock.type],
nativeElement: undefined,
controlFlowBlock: mapToDevtoolsControlFlowModel(controlFlowBlock, iteratorCurrentIdx, rootId),
static: false,
};
}
@@ -195,7 +195,8 @@ const indexTree = <T extends DevToolsNode<DirectiveInstanceType, ComponentInstan
hydration: node.hydration,
controlFlowBlock: node.controlFlowBlock,
injector: node.injector,
} as IndexedNode;
static: node.static,
} satisfies IndexedNode;
};
export const indexForest = <T extends DevToolsNode<DirectiveInstanceType, ComponentInstanceType>>(
@@ -95,6 +95,7 @@ export class LTreeStrategy {
directives: [],
component: null,
controlFlowBlock: null, // neither there will be any control flow block
static: false,
};
}
for (let i = tNode.directiveStart; i < tNode.directiveEnd; i++) {
@@ -120,6 +121,7 @@ export class LTreeStrategy {
directives,
component,
controlFlowBlock: null, // neither there will be any control flow block
static: false,
};
}
@@ -192,4 +192,50 @@ describe('render tree extraction', () => {
}),
);
});
it('should add an `<html_content>` static node for a control flow block with HTML-only content', () => {
// Represent:
//
// <app>
// @for (...) {
// <div>plain HTML, no component/directive</div>
// }
// </app>
const appNode = document.createElement('app');
const forHostNode = document.createElement('comment');
const htmlContentNode = document.createElement('div');
appNode.appendChild(forHostNode);
appNode.appendChild(htmlContentNode);
componentMap.set(appNode, {});
controlFlowBlocksMap.set(appNode, [
{
type: ControlFlowBlockType.For,
hostNode: forHostNode,
rootNodes: [htmlContentNode],
items: [],
},
]);
const rtree = treeStrategy.build(appNode);
expect(rtree[0].children.length).toBe(1);
const deferNode = rtree[0].children[0];
expect(deferNode.tagName).toBe('@for');
expect(deferNode.children.length).toBe(1);
expect(deferNode.children[0]).toEqual(
jasmine.objectContaining({
tagName: '<html_content>',
static: true,
controlFlowBlock: null,
component: null,
children: [],
}),
);
});
});
@@ -61,6 +61,7 @@ function extractViewTree(
tagName: domNode.nodeName.toLowerCase(),
nativeElement: domNode,
hydration: hydrationStatus(domNode),
static: false,
controlFlowBlock: null,
};
@@ -88,9 +89,9 @@ function extractViewTree(
const childrenResult = isDisplayableNode ? componentTreeNode.children : result;
for (const node of domNode.childNodes) {
if (!nodesToSkip.has(node)) {
extractViewTree(node, childrenResult, ctx, nodesToSkip);
for (const child of domNode.childNodes) {
if (!nodesToSkip.has(child)) {
extractViewTree(child, childrenResult, ctx, nodesToSkip);
}
}
}
@@ -110,7 +111,8 @@ function groupControlFlowBlocksChildren(
}
ctx.blocksIterator.advance();
// It's important to store the here index before the recursive call.
// It's important to store the current index before the recursive call
// because it might change at the time of the passing to `createControlFlowTreeNode`.
const iteratorCurrentIdx = ctx.blocksIterator.currentIndex;
const childrenTree: ComponentTreeNode[] = [];
@@ -121,6 +123,20 @@ function groupControlFlowBlocksChildren(
}
}
// If the there isn't a children tree (i.e. child components)
// but the block has root nodes, we create a static node that
// informs the user that the control flow block
// has HTML-only content.
if (!childrenTree.length && currentBlock.rootNodes.length) {
childrenTree.push({
tagName: '<html_content>',
static: true,
controlFlowBlock: null,
component: null,
children: [],
});
}
const blockTreeNode = createControlFlowTreeNode(
currentBlock,
childrenTree,
@@ -21,6 +21,7 @@ const tree1: DevToolsNode = {
],
component: null,
controlFlowBlock: null,
static: false,
children: [
{
@@ -34,6 +35,7 @@ const tree1: DevToolsNode = {
tagName: 'bar',
nativeElement: document.createElement('bar'),
controlFlowBlock: null,
static: false,
},
],
nativeElement: document.createElement('foo'),
@@ -49,6 +51,7 @@ const tree2: DevToolsNode = {
],
component: null,
controlFlowBlock: null,
static: false,
children: [
{
@@ -62,6 +65,7 @@ const tree2: DevToolsNode = {
tagName: 'bar',
nativeElement: document.createElement('bar'),
controlFlowBlock: null,
static: false,
},
{
children: [],
@@ -73,6 +77,7 @@ const tree2: DevToolsNode = {
directives: [],
tagName: 'qux',
controlFlowBlock: null,
static: false,
},
],
nativeElement: document.createElement('foo'),
@@ -88,6 +93,8 @@ const tree3: DevToolsNode = {
],
component: null,
controlFlowBlock: null,
static: false,
children: [
{
children: [],
@@ -99,6 +106,7 @@ const tree3: DevToolsNode = {
directives: [],
tagName: '#comment',
controlFlowBlock: null,
static: false,
nativeElement: document.createComment('bar'),
},
{
@@ -111,6 +119,7 @@ const tree3: DevToolsNode = {
directives: [],
tagName: '#comment',
controlFlowBlock: null,
static: false,
nativeElement: document.createComment('bar'),
},
],
@@ -120,6 +129,7 @@ const tree3: DevToolsNode = {
const tree4: DevToolsNode = {
tagName: 'app',
controlFlowBlock: null,
static: false,
directives: [
{
id: 1,
@@ -146,6 +156,7 @@ const tree4: DevToolsNode = {
directives: [],
tagName: 'bar',
controlFlowBlock: null,
static: false,
nativeElement: document.createComment('bar'),
},
],
@@ -157,6 +168,7 @@ const tree4: DevToolsNode = {
directives: [],
tagName: '#comment',
controlFlowBlock: null,
static: false,
nativeElement: document.createComment('bar'),
},
],
@@ -168,6 +180,7 @@ const tree4: DevToolsNode = {
directives: [],
tagName: '#comment',
controlFlowBlock: null,
static: false,
nativeElement: document.createComment('bar'),
},
],
@@ -179,6 +192,7 @@ const tree4: DevToolsNode = {
directives: [],
tagName: '#comment',
controlFlowBlock: null,
static: false,
nativeElement: document.createComment('bar'),
},
],
@@ -191,6 +205,7 @@ const tree4: DevToolsNode = {
tagName: '#comment',
nativeElement: document.createComment('bar'),
controlFlowBlock: null,
static: false,
},
],
nativeElement: document.createElement('foo'),
@@ -35,6 +35,8 @@ export interface FlatNode {
hydration?: HydrationStatus;
controlFlowBlock: ControlFlowBlock | null;
changeDetection?: ChangeDetection;
collapsedByDefault: boolean;
static: boolean;
hasNativeElement: boolean;
}
@@ -110,8 +112,10 @@ export class ComponentDataSource extends DataSource<FlatNode> {
level,
hydration: node.hydration,
controlFlowBlock: node.controlFlowBlock,
static: node.static,
changeDetection: node.changeDetection,
hasNativeElement: node.hasNativeElement,
collapsedByDefault: node.children.every((n) => n.static),
};
this._nodeToFlat.set(node, flatNode);
return flatNode;
@@ -35,9 +35,12 @@ describe('directive-forest-utils', () => {
directives,
controlFlowBlock: null,
hasNativeElement: true,
static: false,
},
controlFlowBlock: null,
hasNativeElement: true,
collapsedByDefault: false,
static: false,
};
}
@@ -188,6 +188,10 @@ export class DirectiveForestComponent {
}
select(node: FlatNode): void {
if (node.static) {
return;
}
this.populateParents(node.position);
this.selectNode.emit(node.original);
this.selectedNode.set(node);
@@ -347,13 +351,23 @@ export class DirectiveForestComponent {
this.forestRoot = this.dataSource.data[0];
if (!this.initialized && forest && forest.length) {
this.treeControl.expandAll();
for (const n of this.treeControl.dataNodes) {
if (!n.collapsedByDefault) {
this.treeControl.expand(n);
} else {
this.treeControl.collapse(n);
}
}
this.initialized = true;
result.newItems.forEach((item) => (item.newItem = false));
}
// We want to expand them once they are rendered.
// We want to expand them once they are rendered unless
// they are `collapsedByDefault`.
result.newItems.forEach((item) => {
this.treeControl.expand(item);
if (!item.collapsedByDefault) {
this.treeControl.expand(item);
}
});
return result;
}
@@ -43,6 +43,7 @@ describe('indexForest', () => {
changeDetection: 'ng-on-push',
controlFlowBlock: null,
hasNativeElement: true,
static: false,
} as DevToolsNode & {hasNativeElement?: boolean},
{
tagName: 'Child1_2',
@@ -56,11 +57,13 @@ describe('indexForest', () => {
changeDetection: 'ng-on-push',
controlFlowBlock: null,
hasNativeElement: true,
static: false,
} as DevToolsNode & {hasNativeElement?: boolean},
],
changeDetection: 'ng-on-push',
controlFlowBlock: null,
hasNativeElement: true,
static: false,
},
{
tagName: 'Parent2',
@@ -80,6 +83,7 @@ describe('indexForest', () => {
changeDetection: 'ng-eager',
controlFlowBlock: null,
hasNativeElement: true,
static: false,
} as DevToolsNode & {hasNativeElement?: boolean},
{
tagName: 'Child2_2',
@@ -98,11 +102,13 @@ describe('indexForest', () => {
changeDetection: 'ng-eager',
controlFlowBlock: null,
hasNativeElement: true,
static: false,
} as DevToolsNode & {hasNativeElement?: boolean},
],
changeDetection: 'ng-eager',
controlFlowBlock: null,
hasNativeElement: true,
static: false,
},
]),
).toEqual([
@@ -137,6 +143,7 @@ describe('indexForest', () => {
controlFlowBlock: null,
hasNativeElement: true,
injector: undefined,
static: false,
},
{
tagName: 'Child1_2',
@@ -151,15 +158,16 @@ describe('indexForest', () => {
children: [],
controlFlowBlock: null,
changeDetection: 'ng-on-push',
hasNativeElement: true,
injector: undefined,
static: false,
},
],
controlFlowBlock: null,
changeDetection: 'ng-on-push',
hasNativeElement: true,
injector: undefined,
static: false,
},
{
tagName: 'Parent2',
@@ -184,6 +192,7 @@ describe('indexForest', () => {
controlFlowBlock: null,
hasNativeElement: true,
injector: undefined,
static: false,
},
{
tagName: 'Child2_2',
@@ -205,12 +214,14 @@ describe('indexForest', () => {
controlFlowBlock: null,
hasNativeElement: true,
injector: undefined,
static: false,
},
],
changeDetection: 'ng-eager',
controlFlowBlock: null,
hasNativeElement: true,
injector: undefined,
static: false,
},
]);
});
@@ -34,6 +34,7 @@ const indexTree = (
hydration: node.hydration,
controlFlowBlock: node.controlFlowBlock,
changeDetection: node.changeDetection,
static: node.static,
hasNativeElement: (node as any).hasNativeElement,
injector: node.injector,
};
@@ -18,7 +18,7 @@
<div class="node-name" #nodeName>
<span
class="element-name"
[class.non-element]="!node().original.hasNativeElement"
[class.non-element]="!node().original.hasNativeElement && !node().static"
[class.angular-element]="isElement()"
>{{ node().name }}</span
>
@@ -8,7 +8,7 @@
white-space: nowrap;
display: block;
&:hover {
&:not(.static):hover {
background-color: var(--color-tree-node-hovered);
.tree-node-info > .hydration {
@@ -33,8 +33,11 @@
}
}
.non-element {
color: var(--color-tree-node-non-element-name);
&.static {
.element-name {
color: var(--quaternary-contrast);
pointer-events: none;
}
}
.tree-node-info {
@@ -66,6 +69,10 @@
.node-name {
position: relative;
.non-element {
color: var(--color-tree-node-non-element-name);
}
.angular-element {
content: '';
color: var(--color-tree-node-ng-element);
@@ -49,6 +49,7 @@ export type NodeTextMatch = {
'[class.selected]': 'isSelected',
'[class.highlighted]': 'isHighlighted',
'[class.new-node]': 'node().newItem',
'[class.static]': 'node().static',
'(click)': 'selectNode.emit(this.node())',
'(dblclick)': 'selectDomElement.emit(this.node())',
'(mouseenter)': 'highlightNode.emit(this.node())',
@@ -29,6 +29,7 @@ const mockIndexedNode: IndexedNode = {
},
],
controlFlowBlock: null,
static: false,
hasNativeElement: true,
children: [],
tagName: 'foo',
@@ -122,6 +122,7 @@ export interface DevToolsNode<DirType = DirectiveType, CmpType = ComponentType>
resolutionPath?: SerializedInjector[];
hydration?: HydrationStatus;
controlFlowBlock: ControlFlowBlock | null;
static: boolean;
changeDetection?: ChangeDetection;
injector?: Injector;
}