fix(devtools): support for @defer-only blocks; defer declared blocks (#66546)

Add support for `@defer`-only blocks (previously, they weren't rendered in the component tree at all); Fix declared blocks section in the details

PR Close #66546
This commit is contained in:
hawkgs 2026-01-14 17:40:23 +02:00 committed by Alon Mishne
parent 92d2498910
commit 2d4262cfbd
7 changed files with 69 additions and 46 deletions

View file

@ -11,7 +11,7 @@ import {
ɵDeferBlockData as DeferBlockData,
ɵHydratedNode as HydrationNode,
} from '@angular/core';
import {CurrentDeferBlock, HydrationStatus} from '../../../../protocol';
import {RenderedDeferBlock, HydrationStatus} from '../../../../protocol';
import {ComponentTreeNode} from '../interfaces';
import {ngDebugClient} from '../ng-debug-api/ng-debug-api';
@ -126,14 +126,19 @@ function groupDeferChildrenIfNeeded(
getDirectiveMetadata?: FrameworkAgnosticGlobalUtils['getDirectiveMetadata'],
) {
const currentDeferBlock = deferBlocks.currentBlock;
const isFirstDefferedChild = node === currentDeferBlock?.rootNodes[0];
if (isFirstDefferedChild) {
const isFirstDeferredChild = node === currentDeferBlock?.rootNodes[0];
// Handles the case where the @defer is still unresolved but doesn't
// have a placeholder, for instance, by which children we mark
// the position of the block normally. In this case, we use the host.
const isHostNode = node === currentDeferBlock?.hostNode;
if (isFirstDeferredChild || isHostNode) {
deferBlocks.advance();
// When encountering the first child of a defer block
// We create a synthetic TreeNode reprensenting the defer block
// When encountering the first child of a defer block (or the host node),
// we create a synthetic TreeNode representing the defer block.
const childrenTree: ComponentTreeNode[] = [];
currentDeferBlock.rootNodes.forEach((child) => {
for (const child of currentDeferBlock.rootNodes) {
extractViewTree(
child,
childrenTree,
@ -143,7 +148,7 @@ function groupDeferChildrenIfNeeded(
getDirectives,
getDirectiveMetadata,
);
});
}
const deferBlockTreeNode = {
children: childrenTree,
@ -155,7 +160,7 @@ function groupDeferChildrenIfNeeded(
defer: {
id: `deferId-${rootId}-${deferBlocks.currentIndex}`,
state: currentDeferBlock.state,
currentBlock: currentBlock(currentDeferBlock),
renderedBlock: getRenderedBlock(currentDeferBlock),
triggers: groupTriggers(currentDeferBlock.triggers),
blocks: {
hasErrorBlock: currentDeferBlock.hasErrorBlock,
@ -213,12 +218,16 @@ function groupTriggers(triggers: string[]) {
return {defer, hydrate, prefetch};
}
function currentBlock(deferBlock: DeferBlockData): CurrentDeferBlock | null {
function getRenderedBlock(deferBlock: DeferBlockData): RenderedDeferBlock | null {
if (['placeholder', 'loading', 'error'].includes(deferBlock.state)) {
return deferBlock.state as 'placeholder' | 'loading' | 'error';
}
if (deferBlock.state === 'complete') {
return 'defer';
}
return null;
}
export class RTreeStrategy {
supports(): boolean {
return (['getDirectiveMetadata', 'getComponent'] as const).every(
@ -253,7 +262,7 @@ class DeferBlocksIterator {
this.currentIndex++;
}
get currentBlock() {
get currentBlock(): DeferBlockData | undefined {
return this.blocks[this.currentIndex];
}
}

View file

@ -29,18 +29,22 @@
</div>
@if (node().onPush) {
<span class="on-push">OnPush</span>
<span class="trait">OnPush</span>
}
@let defer = node().defer;
@if (!defer && (!hydration || hydration.status !== 'dehydrated')) {
<!-- Shown/hidden via CSS -->
<span class="console-reference"> == $ng0 </span>
<span class="console-reference trait"> == $ng0 </span>
}
@if (defer && defer.currentBlock) {
<span class="on-push">(&#64;{{ defer.currentBlock }})</span>
@if (defer) {
@if (defer.renderedBlock && defer.renderedBlock !== 'defer') {
<span class="trait">(@{{ defer.renderedBlock }})</span>
} @else if (!defer.renderedBlock) {
<span class="trait">(non-rendered)</span>
}
}
@switch (hydration?.status) {

View file

@ -99,8 +99,7 @@
display: none;
}
.console-reference,
.on-push {
.trait {
color: var(--color-tree-node-console-ref);
padding-left: 8px;
font-style: italic;

View file

@ -97,9 +97,9 @@ describe('TreeNodeComponent', () => {
});
await fixture.whenStable();
onPush = fixture.debugElement.query(By.css('.on-push'));
onPush = fixture.debugElement.query(By.css('.trait'));
expect(onPush).toBeTruthy();
expect(onPush.nativeElement.textContent).toEqual('OnPush');
});
it('should handle selection', async () => {

View file

@ -1,30 +1,36 @@
<mat-toolbar>Current block</mat-toolbar>
<mat-toolbar>Rendered block</mat-toolbar>
<div class="defer-details">
<span class="pill"> &#64;{{ defer().currentBlock ?? 'defer' }}</span>
@if (defer().renderedBlock) {
<span class="pill"> &#64;{{ defer().renderedBlock }}</span>
} @else {
<em>Nothing rendered yet</em>
}
</div>
@if (hasDeclaredBlocks()) {
@let blocks = defer().blocks;
<mat-toolbar>Declared blocks</mat-toolbar>
<div class="defer-details">
@if (blocks.placeholderBlock.exists) {
<span class="pill">
&#64;placeholder<!--
-->{{
blocks.placeholderBlock.minimumTime
? `(minimum ${blocks.placeholderBlock.minimumTime} ms)`
: ''
}}</span
>
}
@if (blocks.loadingBlock.exists) {
<span class="pill">&#64;loading{{ loadingBlockInfo() }}</span>
}
@if (blocks.hasErrorBlock) {
<span class="pill">&#64;error</span>
}
</div>
}
@let triggers = defer().triggers;
@let blocks = defer().blocks;
<mat-toolbar>Declared blocks</mat-toolbar>
<div class="defer-details">
@if (blocks.placeholderBlock) {
<span class="pill">
&#64;placeholder<!--
-->{{
blocks.placeholderBlock.minimumTime
? `(minimum ${blocks.placeholderBlock.minimumTime} ms)`
: ''
}}</span
>
}
@if (blocks.loadingBlock) {
<span class="pill">&#64;loading{{ loadingBlockInfo() }}</span>
}
@if (blocks.hasErrorBlock) {
<span class="pill">&#64;error</span>
}
</div>
<mat-toolbar>Triggers</mat-toolbar>
<div class="defer-details">
<h3>Defer triggers</h3>

View file

@ -22,7 +22,7 @@ export class DeferViewComponent {
readonly loadingBlockInfo = computed(() => {
const loadingBlock = this.defer().blocks.loadingBlock;
if (!loadingBlock) {
if (!loadingBlock.exists) {
return null;
}
@ -35,4 +35,9 @@ export class DeferViewComponent {
}
return info.length ? `(${info.join(', ')})` : null;
});
readonly hasDeclaredBlocks = computed(() => {
const blocks = this.defer().blocks;
return blocks.hasErrorBlock || blocks.placeholderBlock.exists || blocks.loadingBlock.exists;
});
}

View file

@ -78,12 +78,12 @@ export type HydrationStatus =
actualNodeDetails: string | null;
};
export type CurrentDeferBlock = 'placeholder' | 'loading' | 'error';
export type RenderedDeferBlock = 'defer' | 'placeholder' | 'loading' | 'error';
export interface DeferInfo {
id: string;
state: 'placeholder' | 'loading' | 'complete' | 'error' | 'initial';
currentBlock: CurrentDeferBlock | null;
renderedBlock: RenderedDeferBlock | null;
triggers: {
defer: string[];
hydrate: string[];
@ -94,8 +94,8 @@ export interface DeferInfo {
export interface BlockDetails {
hasErrorBlock: boolean;
placeholderBlock: null | {minimumTime: number | null};
loadingBlock: null | {minimumTime: number | null; afterTime: number | null};
placeholderBlock: {exists: boolean; minimumTime: number | null};
loadingBlock: {exists: boolean; minimumTime: number | null; afterTime: number | null};
}
// TODO: refactor to remove nativeElement as it is not serializable