refactor(devtools): Add hydration informations (#53910)

This commit adds hydration informations to the devtools.
* List of hydrated/hydrated components
* Shows hydration overlays
* Shows hydration errors for NG0500, 501 & 502

PR Close #53910
This commit is contained in:
Matthieu Riegler 2024-01-19 20:29:24 +01:00 committed by Jessica Janiuk
parent 274a489bb5
commit b560e02cdf
40 changed files with 1347 additions and 129 deletions

View file

@ -40,6 +40,10 @@ ts_test_library(
ts_library(
name = "highlighter",
srcs = ["highlighter.ts"],
deps = [
"//devtools/projects/protocol",
"//packages/core",
],
)
ts_library(
@ -100,6 +104,27 @@ ts_library(
srcs = ["version.ts"],
)
karma_web_test_suite(
name = "client_event_subscribers_test",
deps = [
":client_event_subscribers_test_lib",
],
)
ts_test_library(
name = "client_event_subscribers_test_lib",
srcs = [
"client-event-subscribers.spec.ts",
],
deps = [
":client_event_subscribers",
"//devtools/projects/ng-devtools-backend/src/lib/hooks",
"//devtools/projects/protocol",
"//devtools/projects/shared-utils",
"@npm//rxjs",
],
)
ts_library(
name = "client_event_subscribers",
srcs = ["client-event-subscribers.ts"],

View file

@ -0,0 +1,77 @@
/**
* @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.io/license
*/
import {Events, MessageBus} from 'protocol';
import {subscribeToClientEvents} from './client-event-subscribers';
import {appIsAngular, appIsAngularIvy, appIsSupportedAngularVersion} from 'shared-utils';
import {DirectiveForestHooks} from './hooks/hooks';
import {of} from 'rxjs';
describe('ClientEventSubscriber', () => {
let messageBusMock: MessageBus<Events>;
let appNode: HTMLElement | null = null;
beforeEach(() => {
// mock isAngular et al
appNode = mockAngular();
messageBusMock = jasmine.createSpyObj<MessageBus<Events>>('messageBus', [
'on',
'once',
'emit',
'destroy',
]);
});
afterEach(() => {
// clearing the dom after each test
if (appNode) {
document.body.removeChild(appNode);
appNode = null;
}
});
it('is it Angular ready (testing purposed)', () => {
expect(appIsAngular()).withContext('isAng').toBe(true);
expect(appIsSupportedAngularVersion()).withContext('appIsSupportedAngularVersion').toBe(true);
expect(appIsAngularIvy()).withContext('appIsAngularIvy').toBe(true);
});
it('should setup inspector', () => {
subscribeToClientEvents(messageBusMock, {directiveForestHooks: MockDirectiveForestHooks});
expect(messageBusMock.on).toHaveBeenCalledWith('inspectorStart', jasmine.any(Function));
expect(messageBusMock.on).toHaveBeenCalledWith('inspectorEnd', jasmine.any(Function));
expect(messageBusMock.on).toHaveBeenCalledWith('createHighlightOverlay', jasmine.any(Function));
expect(messageBusMock.on).toHaveBeenCalledWith('removeHighlightOverlay', jasmine.any(Function));
expect(messageBusMock.on).toHaveBeenCalledWith('createHydrationOverlay', jasmine.any(Function));
expect(messageBusMock.on).toHaveBeenCalledWith('removeHydrationOverlay', jasmine.any(Function));
});
});
function mockAngular() {
const appNode = document.createElement('app');
appNode.setAttribute('ng-version', '17.0.0');
(appNode as any).__ngContext__ = true;
document.body.appendChild(appNode);
(window as any) = {
ng: {
getComponent: () => {},
},
};
return appNode;
}
class MockDirectiveForestHooks extends DirectiveForestHooks {
profiler = {
subscribe: () => {},
changeDetection$: of(),
} as any as DirectiveForestHooks['profiler'];
initialize = () => {};
}

View file

@ -25,6 +25,7 @@ import {
appIsAngularIvy,
appIsSupportedAngularVersion,
getAngularVersion,
isHydrationEnabled,
} from 'shared-utils';
import {ComponentInspector} from './component-inspector/component-inspector';
@ -50,9 +51,15 @@ import {ComponentTreeNode} from './interfaces';
import {ngDebugDependencyInjectionApiIsSupported} from './ng-debug-api/ng-debug-api';
import {setConsoleReference} from './set-console-reference';
import {serializeDirectiveState} from './state-serializer/state-serializer';
import {hasDiDebugAPIs, runOutsideAngular} from './utils';
import {runOutsideAngular} from './utils';
import {DirectiveForestHooks} from './hooks/hooks';
export const subscribeToClientEvents = (messageBus: MessageBus<Events>): void => {
export const subscribeToClientEvents = (
messageBus: MessageBus<Events>,
depsForTestOnly?: {
directiveForestHooks?: typeof DirectiveForestHooks;
},
): void => {
messageBus.on('shutdown', shutdownCallback(messageBus));
messageBus.on(
@ -86,7 +93,7 @@ export const subscribeToClientEvents = (messageBus: MessageBus<Events>): void =>
// update requests, instead we want to request an update at most
// once every 250ms
runOutsideAngular(() => {
initializeOrGetDirectiveForestHooks()
initializeOrGetDirectiveForestHooks(depsForTestOnly)
.profiler.changeDetection$.pipe(debounceTime(250))
.subscribe(() => messageBus.emit('componentTreeDirty'));
});
@ -219,7 +226,12 @@ const checkForAngular = (messageBus: MessageBus<Events>): void => {
}
messageBus.emit('ngAvailability', [
{version: ngVersion.toString(), devMode: appIsAngularInDevMode(), ivy: appIsIvy},
{
version: ngVersion.toString(),
devMode: appIsAngularInDevMode(),
ivy: appIsIvy,
hydration: isHydrationEnabled(),
},
]);
};
@ -243,6 +255,9 @@ const setupInspector = (messageBus: MessageBus<Events>) => {
inspector.highlightByPosition(position);
});
messageBus.on('removeHighlightOverlay', unHighlight);
messageBus.on('createHydrationOverlay', inspector.highlightHydrationNodes);
messageBus.on('removeHydrationOverlay', inspector.removeHydrationHighlights);
};
export interface SerializableDirectiveInstanceType extends DirectiveType {
@ -281,6 +296,7 @@ const prepareForestForSerialization = (
id: initializeOrGetDirectiveForestHooks().getDirectiveId(d.instance)!,
})),
children: prepareForestForSerialization(node.children, includeResolutionPath),
hydration: node.hydration,
};
serializedNodes.push(serializedNode);

View file

@ -6,6 +6,7 @@
* found in the LICENSE file at https://angular.io/license
*/
import {initializeOrGetDirectiveForestHooks} from '../hooks';
import {ComponentInspector} from './component-inspector';
describe('ComponentInspector', () => {
@ -42,4 +43,8 @@ describe('ComponentInspector', () => {
expect(mouseEventSpy.stopImmediatePropagation).toHaveBeenCalledTimes(1);
expect(mouseEventSpy.preventDefault).toHaveBeenCalledTimes(1);
});
it('should always retrieve the same forest hook', () => {
expect(initializeOrGetDirectiveForestHooks()).toBe(initializeOrGetDirectiveForestHooks());
});
});

View file

@ -6,10 +6,16 @@
* found in the LICENSE file at https://angular.io/license
*/
import {ElementPosition} from 'protocol';
import {ElementPosition, HydrationStatus} from 'protocol';
import {findNodeInForest} from '../component-tree';
import {findComponentAndHost, highlight, unHighlight} from '../highlighter';
import {
findComponentAndHost,
highlightHydrationElement,
highlightSelectedElement,
removeHydrationHighlights,
unHighlight,
} from '../highlighter';
import {initializeOrGetDirectiveForestHooks} from '../hooks';
import {ComponentTreeNode} from '../interfaces';
@ -74,7 +80,7 @@ export class ComponentInspector {
unHighlight();
if (this._selectedComponent.component && this._selectedComponent.host) {
highlight(this._selectedComponent.host);
highlightSelectedElement(this._selectedComponent.host);
this._onComponentEnter(
initializeOrGetDirectiveForestHooks().getDirectiveId(this._selectedComponent.component)!,
);
@ -99,7 +105,65 @@ export class ComponentInspector {
const forest: ComponentTreeNode[] = initializeOrGetDirectiveForestHooks().getDirectiveForest();
const elementToHighlight: HTMLElement | null = findNodeInForest(position, forest);
if (elementToHighlight) {
highlight(elementToHighlight);
highlightSelectedElement(elementToHighlight);
}
}
highlightHydrationNodes(): void {
const forest: ComponentTreeNode[] = initializeOrGetDirectiveForestHooks().getDirectiveForest();
// drop the root nodes, we don't want to highlight it
const forestWithoutRoots = forest.flatMap((rootNode) => rootNode.children);
const errorNodes = findErrorNodesForHydrationOverlay(forestWithoutRoots);
// We get the first level of hydrated nodes
// nested mismatched nodes nested in hydrated nodes aren't includes
const nodes = findNodesForHydrationOverlay(forestWithoutRoots);
// This ensures top level mismatched nodes are removed as we have a dedicated array
const otherNodes = nodes.filter(({status}) => status?.status !== 'mismatched');
for (const {node, status} of [...otherNodes, ...errorNodes]) {
highlightHydrationElement(node, status);
}
}
removeHydrationHighlights() {
removeHydrationHighlights();
}
}
/**
* Returns the first level of hydrated nodes
* Note: Mismatched nodes nested in hydrated nodes aren't included
*/
function findNodesForHydrationOverlay(
forest: ComponentTreeNode[],
): {node: Node; status: HydrationStatus}[] {
return forest.flatMap((node) => {
if (node?.hydration?.status) {
// We highlight first level
return {node: node.nativeElement!, status: node.hydration};
}
if (node.children.length) {
return findNodesForHydrationOverlay(node.children);
}
return [];
});
}
function findErrorNodesForHydrationOverlay(
forest: ComponentTreeNode[],
): {node: Node; status: HydrationStatus}[] {
return forest.flatMap((node) => {
if (node?.hydration?.status === 'mismatched') {
// We highlight first level
return {node: node.nativeElement!, status: node.hydration};
}
if (node.children.length) {
return findNodesForHydrationOverlay(node.children);
}
return [];
});
}

View file

@ -63,7 +63,7 @@ export function getInjectorId() {
return `${injectorId++}`;
}
function getInjectorMetadata(injector: Injector) {
export function getInjectorMetadata(injector: Injector) {
return ngDebugClient().ɵgetInjectorMetadata(injector);
}

View file

@ -15,6 +15,8 @@ ts_library(
"//devtools/projects/ng-devtools-backend/src/lib:utils",
"//devtools/projects/ng-devtools-backend/src/lib:version",
"//devtools/projects/ng-devtools-backend/src/lib/ng-debug-api",
"//devtools/projects/protocol",
"//packages/core",
"@npm//semver-dsl",
],
)

View file

@ -90,6 +90,7 @@ export class LTreeStrategy {
element,
directives: [],
component: null,
hydration: null, // We know there is no hydration if we use the LTreeStrategy
};
}
for (let i = tNode.directiveStart; i < tNode.directiveEnd; i++) {
@ -114,6 +115,7 @@ export class LTreeStrategy {
element,
directives,
component,
hydration: null, // We know there is no hydration if we use the LTreeStrategy
};
}

View file

@ -6,6 +6,9 @@
* found in the LICENSE file at https://angular.io/license
*/
import {ɵHydratedNode as HydrationNode} from '@angular/core';
import {HydrationStatus} from 'protocol';
import {ComponentTreeNode} from '../interfaces';
import {ngDebugClient} from '../ng-debug-api/ng-debug-api';
import {isCustomElement} from '../utils';
@ -31,6 +34,7 @@ const extractViewTree = (
}),
element: domNode.nodeName.toLowerCase(),
nativeElement: domNode,
hydration: hydrationStatus(domNode as HydrationNode),
};
if (!(domNode instanceof Element)) {
result.push(componentTreeNode);
@ -59,6 +63,23 @@ const extractViewTree = (
return result;
};
function hydrationStatus(node: HydrationNode): HydrationStatus {
switch (node.__ngDebugHydrationInfo__?.status) {
case 'hydrated':
return {status: 'hydrated'};
case 'skipped':
return {status: 'skipped'};
case 'mismatched':
return {
status: 'mismatched',
expectedNodeDetails: node.__ngDebugHydrationInfo__.expectedNodeDetails,
actualNodeDetails: node.__ngDebugHydrationInfo__.actualNodeDetails,
};
default:
return null;
}
}
export class RTreeStrategy {
supports(): boolean {
return (['getDirectiveMetadata', 'getComponent', 'getDirectives'] as const).every(

View file

@ -99,4 +99,108 @@ describe('highlighter', () => {
expect(highlighter.inDoc(node)).toBeTruthy();
});
});
describe('highlightHydrationElement', () => {
afterEach(() => {
document.body.innerHTML = '';
delete (window as any).ng;
});
it('should show hydration overlay with svg', () => {
const appNode = document.createElement('app');
appNode.style.width = '500px';
appNode.style.height = '400px';
appNode.style.display = 'block';
document.body.appendChild(appNode);
(window as any).ng = {
getComponent: (el: any) => el,
};
highlighter.highlightHydrationElement(appNode, {status: 'hydrated'});
expect(document.body.querySelectorAll('div').length).toBe(2);
expect(document.body.querySelectorAll('svg').length).toBe(1);
const overlay = document.body.querySelector('.ng-devtools-overlay');
expect(overlay?.getBoundingClientRect().width).toBe(500);
expect(overlay?.getBoundingClientRect().height).toBe(400);
highlighter.removeHydrationHighlights();
expect(document.body.querySelectorAll('div').length).toBe(0);
expect(document.body.querySelectorAll('svg').length).toBe(0);
});
it('should show hydration overlay without svg (too small)', () => {
const appNode = document.createElement('app');
appNode.style.width = '25px';
appNode.style.height = '20px';
appNode.style.display = 'block';
document.body.appendChild(appNode);
(window as any).ng = {
getComponent: (el: any) => el,
};
highlighter.highlightHydrationElement(appNode, {status: 'hydrated'});
expect(document.body.querySelectorAll('div').length).toBe(1);
expect(document.body.querySelectorAll('svg').length).toBe(0);
const overlay = document.body.querySelector('.ng-devtools-overlay');
expect(overlay?.getBoundingClientRect().width).toBe(25);
expect(overlay?.getBoundingClientRect().height).toBe(20);
highlighter.removeHydrationHighlights();
expect(document.body.querySelectorAll('div').length).toBe(0);
expect(document.body.querySelectorAll('svg').length).toBe(0);
});
it('should show hydration overlay and selected component overlay at the same time ', () => {
const appNode = document.createElement('app');
appNode.style.width = '25px';
appNode.style.height = '20px';
appNode.style.display = 'block';
document.body.appendChild(appNode);
(window as any).ng = {
getComponent: (el: any) => el,
};
highlighter.highlightHydrationElement(appNode, {status: 'hydrated'});
highlighter.highlightSelectedElement(appNode);
expect(document.body.querySelectorAll('.ng-devtools-overlay').length).toBe(2);
highlighter.removeHydrationHighlights();
expect(document.body.querySelectorAll('.ng-devtools-overlay').length).toBe(1);
highlighter.unHighlight();
expect(document.body.querySelectorAll('.ng-devtools-overlay').length).toBe(0);
highlighter.highlightHydrationElement(appNode, {status: 'hydrated'});
highlighter.highlightSelectedElement(appNode);
highlighter.unHighlight();
expect(document.body.querySelectorAll('.ng-devtools-overlay').length).toBe(1);
highlighter.removeHydrationHighlights();
expect(document.body.querySelectorAll('.ng-devtools-overlay').length).toBe(0);
});
});
describe('highlightSelectedElement', () => {
it('should show overlay', () => {
const appNode = document.createElement('app');
appNode.style.width = '25px';
appNode.style.height = '20px';
appNode.style.display = 'block';
document.body.appendChild(appNode);
(window as any).ng = {
getComponent: (el: any) => new (class FakeComponent {})(),
};
highlighter.highlightSelectedElement(appNode);
const overlay = document.body.querySelectorAll('.ng-devtools-overlay');
expect(overlay.length).toBe(1);
expect(overlay[0].innerHTML).toContain('FakeComponent');
highlighter.unHighlight();
expect(document.body.querySelectorAll('.ng-devtools-overlay').length).toBe(0);
});
});
});

View file

@ -6,31 +6,47 @@
* found in the LICENSE file at https://angular.io/license
*/
let overlay: any;
let overlayContent: HTMLElement;
import type {ɵGlobalDevModeUtils as GlobalDevModeUtils, Type} from '@angular/core';
import {HydrationStatus} from 'protocol';
declare const ng: any;
let hydrationOverlayItems: HTMLElement[] = [];
let selectedElementOverlay: HTMLElement | null = null;
interface Type<T> extends Function {
new (...args: any[]): T;
}
declare const ng: GlobalDevModeUtils['ng'];
const DEV_TOOLS_HIGHLIGHT_NODE_ID = '____ngDevToolsHighlight';
function init(): void {
if (overlay) {
return;
}
overlay = document.createElement('div');
overlay.style.backgroundColor = 'rgba(104, 182, 255, 0.35)';
const OVERLAY_CONTENT_MARGIN = 4;
const MINIMAL_OVERLAY_CONTENT_SIZE = {
width: 30 + OVERLAY_CONTENT_MARGIN * 2,
height: 20 + OVERLAY_CONTENT_MARGIN * 2,
};
type RgbColor = readonly [red: number, green: number, blue: number];
const COLORS = {
blue: [104, 182, 255],
red: [255, 0, 64],
grey: [128, 128, 128],
} satisfies Record<string, RgbColor>;
// Those are the SVG we inline in case the overlay label is to long for the container component.
const HYDRATION_SVG = `
<svg xmlns="http://www.w3.org/2000/svg" enable-background="new 0 0 24 24" height="24px" viewBox="0 0 24 24" width="24px" fill="#000000"><rect fill="none" height="24" width="24"/><path d="M12,2c-5.33,4.55-8,8.48-8,11.8c0,4.98,3.8,8.2,8,8.2s8-3.22,8-8.2C20,10.48,17.33,6.55,12,2z M12,20c-3.35,0-6-2.57-6-6.2 c0-2.34,1.95-5.44,6-9.14c4.05,3.7,6,6.79,6,9.14C18,17.43,15.35,20,12,20z M7.83,14c0.37,0,0.67,0.26,0.74,0.62 c0.41,2.22,2.28,2.98,3.64,2.87c0.43-0.02,0.79,0.32,0.79,0.75c0,0.4-0.32,0.73-0.72,0.75c-2.13,0.13-4.62-1.09-5.19-4.12 C7.01,14.42,7.37,14,7.83,14z"/></svg>`;
const HYDRATION_SKIPPED_SVG = `<svg xmlns="http://www.w3.org/2000/svg" enable-background="new 0 0 24 24" height="24" viewBox="0 0 24 24" width="24"><rect fill="none" height="24" width="24"/><path d="M21.19,21.19L2.81,2.81L1.39,4.22l4.2,4.2c-1,1.31-1.6,2.94-1.6,4.7C4,17.48,7.58,21,12,21c1.75,0,3.36-0.56,4.67-1.5 l3.1,3.1L21.19,21.19z M12,19c-3.31,0-6-2.63-6-5.87c0-1.19,0.36-2.32,1.02-3.28L12,14.83V19z M8.38,5.56L12,2l5.65,5.56l0,0 C19.1,8.99,20,10.96,20,13.13c0,1.18-0.27,2.29-0.74,3.3L12,9.17V4.81L9.8,6.97L8.38,5.56z"/></svg>`;
const HYDRATION_ERROR_SVG = `<svg xmlns="http://www.w3.org/2000/svg" height="24" viewBox="0 0 24 24" width="24"><path d="M11 15h2v2h-2v-2zm0-8h2v6h-2V7zm.99-5C6.47 2 2 6.48 2 12s4.47 10 9.99 10C17.52 22 22 17.52 22 12S17.52 2 11.99 2zM12 20c-4.42 0-8-3.58-8-8s3.58-8 8-8 8 3.58 8 8-3.58 8-8 8z"/></svg>`;
function createOverlay(color: RgbColor): {overlay: HTMLElement; overlayContent: HTMLElement} {
const overlay = document.createElement('div');
overlay.className = 'ng-devtools-overlay';
overlay.style.backgroundColor = toCSSColor(...color, 0.35);
overlay.style.position = 'fixed';
overlay.style.zIndex = '2147483647';
overlay.style.pointerEvents = 'none';
overlay.style.display = 'flex';
overlay.style.borderRadius = '3px';
overlay.id = DEV_TOOLS_HIGHLIGHT_NODE_ID;
overlayContent = document.createElement('div');
overlayContent.style.backgroundColor = 'rgba(104, 182, 255, 0.9)';
const overlayContent = document.createElement('div');
overlayContent.style.backgroundColor = toCSSColor(...color, 0.9);
overlayContent.style.position = 'absolute';
overlayContent.style.fontFamily = 'monospace';
overlayContent.style.fontSize = '11px';
@ -38,11 +54,13 @@ function init(): void {
overlayContent.style.borderRadius = '3px';
overlayContent.style.color = 'white';
overlay.appendChild(overlayContent);
return {overlay, overlayContent};
}
export const findComponentAndHost = (
el: Node | undefined,
): {component: any; host: HTMLElement | null} => {
export function findComponentAndHost(el: Node | undefined): {
component: any;
host: HTMLElement | null;
} {
if (!el) {
return {component: null, host: null};
}
@ -57,41 +75,46 @@ export const findComponentAndHost = (
el = el.parentElement;
}
return {component: null, host: null};
};
}
// Todo(aleksanderbodurri): this should not be part of the highlighter, move this somewhere else
export function getDirectiveName(dir: Type<unknown> | undefined | null): string {
return dir ? dir.constructor.name : 'unknown';
}
export function highlight(el: HTMLElement): void {
const cmp = findComponentAndHost(el).component;
const rect = getComponentRect(el);
export function highlightSelectedElement(el: Node): void {
selectedElementOverlay = addHighlightForElement(el);
}
init();
if (rect) {
const content: Node[] = [];
const name = getDirectiveName(cmp);
if (name) {
const pre = document.createElement('span');
pre.style.opacity = '0.6';
pre.innerText = '<';
const text = document.createTextNode(name);
const post = document.createElement('span');
post.style.opacity = '0.6';
post.innerText = '>';
content.push(pre, text, post);
}
showOverlay(rect, content);
export function highlightHydrationElement(el: Node, status: HydrationStatus) {
let overlay: HTMLElement | null = null;
if (status?.status === 'skipped') {
overlay = addHighlightForElement(el, COLORS.grey, status?.status);
} else if (status?.status === 'mismatched') {
overlay = addHighlightForElement(el, COLORS.red, status?.status);
} else if (status?.status === 'hydrated') {
overlay = addHighlightForElement(el, COLORS.blue, status?.status);
}
if (overlay) {
hydrationOverlayItems.push(overlay);
}
}
export function unHighlight(): void {
if (overlay && overlay.parentNode) {
document.body.removeChild(overlay);
if (selectedElementOverlay) {
document.body.removeChild(selectedElementOverlay);
selectedElementOverlay = null;
}
}
export function removeHydrationHighlights(): void {
hydrationOverlayItems.forEach((overlay) => {
document.body.removeChild(overlay);
});
hydrationOverlayItems = [];
}
export function inDoc(node: any): boolean {
if (!node) {
return false;
@ -103,6 +126,46 @@ export function inDoc(node: any): boolean {
);
}
function addHighlightForElement(
el: Node,
color: RgbColor = COLORS.blue,
overlayType?: NonNullable<HydrationStatus>['status'],
): HTMLElement | null {
const cmp = findComponentAndHost(el).component;
const rect = getComponentRect(el);
if (rect?.height === 0 || rect?.width === 0) {
// display nothing in case the component is not visible
return null;
}
const {overlay, overlayContent} = createOverlay(color);
if (!rect) return null;
const content: Node[] = [];
const componentName = getDirectiveName(cmp);
// We display an icon inside the overlay if the container computer is wide enough
if (overlayType) {
if (
rect.width > MINIMAL_OVERLAY_CONTENT_SIZE.width &&
rect.height > MINIMAL_OVERLAY_CONTENT_SIZE.height
) {
// 30x20 + 8px margin
const svg = createOverlaySvgElement(overlayType!);
content.push(svg);
}
} else if (componentName) {
const middleText = document.createTextNode(componentName);
const pre = document.createElement('span');
pre.innerText = `<`;
const post = document.createElement('span');
post.innerText = `>`;
content.push(pre, middleText, post);
}
showOverlay(overlay, overlayContent, rect, content, overlayType ? 'inside' : 'outside');
return overlay;
}
function getComponentRect(el: Node): DOMRect | undefined {
if (!(el instanceof HTMLElement)) {
return;
@ -113,27 +176,48 @@ function getComponentRect(el: Node): DOMRect | undefined {
return el.getBoundingClientRect();
}
function showOverlay(dimensions: DOMRect, content: Node[]): void {
function showOverlay(
overlay: HTMLElement,
overlayContent: HTMLElement,
dimensions: DOMRect,
content: Node[],
labelPosition: 'inside' | 'outside',
): void {
const {width, height, top, left} = dimensions;
overlay.style.width = ~~width + 'px';
overlay.style.height = ~~height + 'px';
overlay.style.top = ~~top + 'px';
overlay.style.left = ~~left + 'px';
positionOverlayContent(dimensions);
positionOverlayContent(overlayContent, dimensions, labelPosition);
overlayContent.replaceChildren();
content.forEach((child) => overlayContent.appendChild(child));
if (content.length) {
content.forEach((child) => overlayContent.appendChild(child));
} else {
// If the overlay label has no content, remove it from the DOM.
overlay.removeChild(overlayContent);
}
document.body.appendChild(overlay);
}
function positionOverlayContent(dimensions: DOMRect) {
function positionOverlayContent(
overlayContent: HTMLElement,
dimensions: DOMRect,
labelPosition: 'inside' | 'outside',
) {
const {innerWidth: viewportWidth, innerHeight: viewportHeight} = window;
const style = overlayContent.style;
const yOffset = 23;
const yOffsetValue = `-${yOffset}px`;
if (labelPosition === 'inside') {
style.top = `${OVERLAY_CONTENT_MARGIN}px`;
style.right = `${OVERLAY_CONTENT_MARGIN}px`;
return;
}
// Clear any previous positioning styles.
style.top = style.bottom = style.left = style.right = '';
@ -161,3 +245,25 @@ function positionOverlayContent(dimensions: DOMRect) {
style.right = `${Math.max(dimensions.right - viewportWidth, 0)}px`;
}
}
function toCSSColor(red: number, green: number, blue: number, alpha = 1): string {
return `rgba(${red},${green},${blue},${alpha})`;
}
function createOverlaySvgElement(type: NonNullable<HydrationStatus>['status']): Node {
let icon: string;
if (type === 'hydrated') {
icon = HYDRATION_SVG;
} else if (type === 'mismatched') {
icon = HYDRATION_ERROR_SVG;
} else if (type === 'skipped') {
icon = HYDRATION_SKIPPED_SVG;
} else {
throw new Error(`No icon specified for type ${type}`);
}
const svg = new DOMParser().parseFromString(icon, 'image/svg+xml').childNodes[0] as SVGElement;
svg.style.fill = 'white';
svg.style.height = '1.5em';
return svg;
}

View file

@ -131,6 +131,7 @@ const indexTree = <T extends DevToolsNode<DirectiveInstanceType, ComponentInstan
directives: node.directives.map((d) => ({position, ...d})),
children: node.children.map((n, i) => indexTree(n, i, position)),
nativeElement: node.nativeElement,
hydration: node.hydration,
} as IndexedNode;
};

View file

@ -50,11 +50,23 @@ export const disableTimingAPI = () => (timingAPIFlag = false);
const timingAPIEnabled = () => timingAPIFlag;
let directiveForestHooks: DirectiveForestHooks;
export const initializeOrGetDirectiveForestHooks = () => {
export const initializeOrGetDirectiveForestHooks = (
depsForTestOnly: {
directiveForestHooks?: typeof DirectiveForestHooks;
} = {},
) => {
// Allow for overriding the DirectiveForestHooks implementation for testing purposes.
if (depsForTestOnly.directiveForestHooks) {
directiveForestHooks = new depsForTestOnly.directiveForestHooks();
}
if (directiveForestHooks) {
return directiveForestHooks;
} else {
directiveForestHooks = new DirectiveForestHooks();
}
directiveForestHooks = new DirectiveForestHooks();
directiveForestHooks.profiler.subscribe({
onChangeDetectionStart(component: any): void {
if (!timingAPIEnabled()) {

View file

@ -31,6 +31,7 @@
<div class="tab-content">
<ng-directive-explorer
[showCommentNodes]="showCommentNodes"
[isHydrationEnabled]="isHydrationEnabled"
[class.hidden]="activeTab !== 'Components'"
(toggleInspector)="toggleInspector()"
/>

View file

@ -50,6 +50,8 @@ type Tabs = 'Components' | 'Profiler' | 'Router Tree' | 'Injector Tree';
})
export class DevToolsTabsComponent implements OnInit, AfterViewInit {
@Input() angularVersion: string | undefined = undefined;
@Input() isHydrationEnabled = false;
@ViewChild(DirectiveExplorerComponent) directiveExplorer!: DirectiveExplorerComponent;
@ViewChild('navBar', {static: true}) navbar!: MatTabNav;

View file

@ -43,10 +43,14 @@ ts_test_library(
srcs = ["directive-explorer.spec.ts"],
deps = [
":directive-explorer",
"//devtools/projects/ng-devtools/src/lib/application-operations",
"//devtools/projects/ng-devtools/src/lib/devtools-tabs/directive-explorer/directive-forest",
"//devtools/projects/ng-devtools/src/lib/devtools-tabs/directive-explorer/directive-forest/index-forest",
"//devtools/projects/ng-devtools/src/lib/devtools-tabs/directive-explorer/property-resolver",
"//devtools/projects/protocol",
"//packages/core",
"//packages/core/testing",
"//packages/platform-browser",
],
)

View file

@ -21,12 +21,12 @@
(mouseOverNode)="highlight($event)"
(handleSelect)="handleSelect($event)"
[parents]="parents"
/>
/>
}
</as-split-area>
</as-split>
</as-split-area>
<as-split-area size="40" minSize="25">
<as-split-area size="40" minSize="25" class="prop-split">
<div class="property-tab-wrapper">
<ng-property-tab
[currentSelectedElement]="currentSelectedElement"
@ -34,5 +34,14 @@
(viewSource)="viewSource($event)"
/>
</div>
@if(isHydrationEnabled) {
<div class="hydration">
<mat-slide-toggle
[(ngModel)]="showHydrationNodeHighlights"
(change)="showHydrationNodeHighlights ? hightlightHydrationNodes() : removeHydrationNodesHightlights()"
>Show hydration overlays</mat-slide-toggle
>
</div>
}
</as-split-area>
</as-split>

View file

@ -12,8 +12,17 @@
}
}
.prop-split {
display: flex;
flex-direction: column;
}
.property-tab-wrapper {
flex: 1;
overflow: auto;
height: 100%;
width: 100%;
}
.hydration {
padding: 4px 8px;
}

View file

@ -44,6 +44,8 @@ import {
} from './property-resolver/element-property-resolver';
import {PropertyTabComponent} from './property-tab/property-tab.component';
import {SplitAreaDirective} from '../../vendor/angular-split/lib/component/splitArea.directive';
import {MatSlideToggle} from '@angular/material/slide-toggle';
import {FormsModule} from '@angular/forms';
const sameDirectives = (a: IndexedNode, b: IndexedNode) => {
if ((a.component && !b.component) || (!a.component && b.component)) {
@ -78,10 +80,13 @@ const sameDirectives = (a: IndexedNode, b: IndexedNode) => {
DirectiveForestComponent,
BreadcrumbsComponent,
PropertyTabComponent,
MatSlideToggle,
FormsModule,
],
})
export class DirectiveExplorerComponent implements OnInit, OnDestroy {
@Input() showCommentNodes = false;
@Input() isHydrationEnabled = false;
@Output() toggleInspector = new EventEmitter<void>();
@ViewChild(DirectiveForestComponent) directiveForest!: DirectiveForestComponent;
@ -94,11 +99,13 @@ export class DirectiveExplorerComponent implements OnInit, OnDestroy {
forest!: DevToolsNode[];
splitDirection: 'horizontal' | 'vertical' = 'horizontal';
parents: FlatNode[] | null = null;
showHydrationNodeHighlights: boolean = false;
private _resizeObserver = new ResizeObserver((entries) =>
this._ngZone.run(() => {
const resizedEntry = entries[0];
this.refreshHydrationNodeHighlightsIfNeeded();
const resizedEntry = entries[0];
if (resizedEntry.target === this.splitElementRef.nativeElement) {
this.splitDirection = resizedEntry.contentRect.width <= 500 ? 'vertical' : 'horizontal';
}
@ -177,6 +184,7 @@ export class DirectiveExplorerComponent implements OnInit, OnDestroy {
if (!this._refreshRetryTimeout) {
this._refreshRetryTimeout = setTimeout(() => this.refresh(), 500);
}
this.refreshHydrationNodeHighlightsIfNeeded();
}
viewSource(directiveName: string): void {
@ -272,4 +280,19 @@ export class DirectiveExplorerComponent implements OnInit, OnDestroy {
const objectPath = constructPathOfKeysToPropertyValue(node.prop);
this._appOperations.inspect(directivePosition, objectPath);
}
hightlightHydrationNodes() {
this._messageBus.emit('createHydrationOverlay');
}
removeHydrationNodesHightlights() {
this._messageBus.emit('removeHydrationOverlay');
}
refreshHydrationNodeHighlightsIfNeeded() {
if (this.showHydrationNodeHighlights) {
this.removeHydrationNodesHightlights();
this.hightlightHydrationNodes();
}
}
}

View file

@ -6,36 +6,54 @@
* found in the LICENSE file at https://angular.io/license
*/
import {PropertyQueryTypes} from 'protocol';
import {CUSTOM_ELEMENTS_SCHEMA} from '@angular/core';
import {ComponentFixture, TestBed} from '@angular/core/testing';
import {Events, MessageBus, PropertyQueryTypes} from 'protocol';
import {ApplicationOperations} from '../../application-operations';
import {DirectiveExplorerComponent} from './directive-explorer.component';
import {DirectiveForestComponent} from './directive-forest/directive-forest.component';
import {IndexedNode} from './directive-forest/index-forest';
import {ElementPropertyResolver} from './property-resolver/element-property-resolver';
import SpyObj = jasmine.SpyObj;
import {By} from '@angular/platform-browser';
describe('DirectiveExplorerComponent', () => {
let messageBusMock: any;
let messageBusMock: SpyObj<MessageBus<Events>>;
let fixture: ComponentFixture<DirectiveExplorerComponent>;
let comp: DirectiveExplorerComponent;
let applicationOperationsSpy: any;
let cdr: any;
let ngZone: any;
let applicationOperationsSpy: SpyObj<ApplicationOperations>;
beforeEach(() => {
applicationOperationsSpy = jasmine.createSpyObj('_appOperations', [
applicationOperationsSpy = jasmine.createSpyObj<ApplicationOperations>('_appOperations', [
'viewSource',
'selectDomElement',
]);
messageBusMock = jasmine.createSpyObj('messageBus', ['on', 'once', 'emit', 'destroy']);
cdr = jasmine.createSpyObj('_cdr', ['detectChanges']);
ngZone = jasmine.createSpyObj('_ngZone', ['run']);
comp = new DirectiveExplorerComponent(
applicationOperationsSpy,
messageBusMock,
new ElementPropertyResolver(messageBusMock),
cdr,
ngZone,
);
messageBusMock = jasmine.createSpyObj<MessageBus<Events>>('messageBus', [
'on',
'once',
'emit',
'destroy',
]);
TestBed.configureTestingModule({
providers: [
{provide: ApplicationOperations, useValue: applicationOperationsSpy},
{provide: MessageBus, useValue: messageBusMock},
{
provide: ElementPropertyResolver,
useValue: new ElementPropertyResolver(messageBusMock),
},
],
}).overrideComponent(DirectiveExplorerComponent, {
add: {schemas: [CUSTOM_ELEMENTS_SCHEMA]},
remove: {imports: [DirectiveForestComponent]},
});
fixture = TestBed.createComponent(DirectiveExplorerComponent);
comp = fixture.componentInstance;
});
it('should create instance from class', () => {
@ -104,4 +122,28 @@ describe('DirectiveExplorerComponent', () => {
]);
});
});
describe('hydration', () => {
it('should highlight hydration nodes', () => {
comp.hightlightHydrationNodes();
expect(messageBusMock.emit).toHaveBeenCalledTimes(1);
expect(messageBusMock.emit).toHaveBeenCalledWith('createHydrationOverlay');
comp.removeHydrationNodesHightlights();
expect(messageBusMock.emit).toHaveBeenCalledTimes(2);
expect(messageBusMock.emit).toHaveBeenCalledWith('removeHydrationOverlay');
});
it('should show hydration slide toggle', () => {
comp.isHydrationEnabled = true;
fixture.detectChanges();
const toggle = fixture.debugElement.query(By.css('mat-slide-toggle'));
expect(toggle).toBeTruthy();
comp.isHydrationEnabled = false;
fixture.detectChanges();
const toggle2 = fixture.debugElement.query(By.css('mat-slide-toggle'));
expect(toggle2).toBeFalsy();
});
});
});

View file

@ -20,6 +20,7 @@ const tree1: DevToolsNode = {
},
],
component: null,
hydration: null,
children: [
{
children: [],
@ -30,6 +31,7 @@ const tree1: DevToolsNode = {
},
directives: [],
element: 'bar',
hydration: null,
nativeElement: document.createElement('bar'),
},
],
@ -45,6 +47,7 @@ const tree2: DevToolsNode = {
},
],
component: null,
hydration: null,
children: [
{
children: [],
@ -55,6 +58,7 @@ const tree2: DevToolsNode = {
},
directives: [],
element: 'bar',
hydration: null,
nativeElement: document.createElement('bar'),
},
{
@ -66,6 +70,7 @@ const tree2: DevToolsNode = {
},
directives: [],
element: 'qux',
hydration: null,
nativeElement: document.createElement('qux'),
},
],
@ -81,6 +86,7 @@ const tree3: DevToolsNode = {
},
],
component: null,
hydration: null,
children: [
{
children: [],
@ -91,6 +97,7 @@ const tree3: DevToolsNode = {
},
directives: [],
element: '#comment',
hydration: null,
nativeElement: document.createComment('bar'),
},
{
@ -102,6 +109,7 @@ const tree3: DevToolsNode = {
},
directives: [],
element: '#comment',
hydration: null,
nativeElement: document.createComment('bar'),
},
],
@ -110,6 +118,7 @@ const tree3: DevToolsNode = {
const tree4: DevToolsNode = {
element: 'app',
hydration: null,
directives: [
{
id: 1,
@ -135,6 +144,7 @@ const tree4: DevToolsNode = {
},
directives: [],
element: 'bar',
hydration: null,
nativeElement: document.createComment('bar'),
},
],
@ -145,6 +155,7 @@ const tree4: DevToolsNode = {
},
directives: [],
element: '#comment',
hydration: null,
nativeElement: document.createComment('bar'),
},
],
@ -155,6 +166,7 @@ const tree4: DevToolsNode = {
},
directives: [],
element: '#comment',
hydration: null,
nativeElement: document.createComment('bar'),
},
],
@ -165,6 +177,7 @@ const tree4: DevToolsNode = {
},
directives: [],
element: '#comment',
hydration: null,
nativeElement: document.createComment('bar'),
},
],
@ -175,6 +188,7 @@ const tree4: DevToolsNode = {
},
directives: [],
element: '#comment',
hydration: null,
nativeElement: document.createComment('bar'),
},
],

View file

@ -10,7 +10,7 @@ import {CollectionViewer, DataSource} from '@angular/cdk/collections';
import {FlatTreeControl} from '@angular/cdk/tree';
import {DefaultIterableDiffer, TrackByFunction} from '@angular/core';
import {MatTreeFlattener} from '@angular/material/tree';
import {DevToolsNode} from 'protocol';
import {DevToolsNode, HydrationStatus} from 'protocol';
import {BehaviorSubject, merge, Observable} from 'rxjs';
import {map} from 'rxjs/operators';
@ -27,6 +27,7 @@ export interface FlatNode {
level: number;
original: IndexedNode;
newItem?: boolean;
hydration: HydrationStatus;
}
const expandable = (node: IndexedNode) => !!node.children && node.children.length > 0;
@ -92,6 +93,7 @@ export class ComponentDataSource extends DataSource<FlatNode> {
directives: node.directives.map((d) => d.name).join(', '),
original: node,
level,
hydration: node.hydration,
};
this._nodeToFlat.set(node, flatNode);
return flatNode;

View file

@ -3,16 +3,16 @@
(filter)="handleFilter($event)"
(nextMatched)="nextMatched()"
(prevMatched)="prevMatched()"
>
>
</ng-filter>
<cdk-virtual-scroll-viewport class="tree-wrapper" [itemSize]="itemHeight">
<ng-container *cdkVirtualFor="let node of dataSource; let idx = index">
<div
[class]="{
matched: isMatched(node),
selected: isSelected(node),
highlighted: isHighlighted(node),
'new-node': node.newItem
matched: isMatched(node),
selected: isSelected(node),
highlighted: isHighlighted(node),
'new-node': node.newItem
}"
class="tree-node"
(click)="selectAndEnsureVisible(node)"
@ -20,25 +20,54 @@
(mouseenter)="highlightNode(node.position)"
(mouseleave)="removeHighlight()"
[style.padding-left]="15 + 15 * node.level + 'px'"
>
>
<div class="tree-node-info">
@if (node.expandable) {
<button
[style.left]="15 * node.level + 'px'"
(click)="treeControl.toggle(node)"
[attr.aria-label]="'toggle ' + node.name"
>
>
<mat-icon class="mat-icon-rtl-mirror">
{{ treeControl.isExpanded(node) ? 'expand_more' : 'chevron_right' }}
</mat-icon>
</button>
}
<span class="element-name" [class.angular-element]="isElement(node)">{{ node.name }}</span>
@if (node.directives) {
<span class="dir-names">[{{ node.directives }}]</span>
}
@if (isSelected(node)) {
<span class="console-reference"> == $ng0 </span>
}
@switch(node.hydration?.status) {
@case('hydrated') {
<mat-icon matTooltip="Hydrated" class="hydration">water_drop</mat-icon>
}
@case('skipped') {
<mat-icon matTooltip="Skipped" class="hydration skipped">invert_colors_off</mat-icon>
}
@case('mismatched') {
<mat-icon matTooltip="Mismatch" class="hydration mismatched">error_outline</mat-icon>
}
}
</div>
@if(node.hydration?.status === 'mismatched' && (node.hydration.expectedNodeDetails || node.hydration.actualNodeDetails)) {
<div class="hydration-error">
@if(node.hydration.expectedNodeDetails) {
<div>Expected Dom:</div>
<pre>{{node.hydration.expectedNodeDetails}}</pre>
}
@if(node.hydration.actualNodeDetails) {
<div>Actual Dom:</div>
<pre>{{node.hydration.actualNodeDetails}}</pre>
}
</div>
}
</div>
</ng-container>
</cdk-virtual-scroll-viewport>

View file

@ -15,13 +15,17 @@
white-space: nowrap;
text-overflow: ellipsis;
& > button {
outline: none;
border: 0;
padding: 0;
position: absolute;
background-color: transparent;
top: 2px;
.tree-node-info {
display: flex;
& > button {
outline: none;
border: 0;
padding: 0;
position: absolute;
background-color: transparent;
top: 2px;
}
}
.mat-icon {
@ -57,6 +61,23 @@
&.highlighted {
background-color: #cfe8fc;
}
.hydration {
margin: auto 8px auto auto;
font-size: 14px;
color: #60a6fc;
position: sticky;
right: 0;
&.skipped {
color: #9a9a9a;
}
&.mismatched {
color: #ff0040;
}
}
}
}
@ -122,6 +143,23 @@
}
}
.hydration-error {
color: #e62222;
margin-left: 16px;
padding: 0 8px 8px 0px;
pre {
margin: 0;
background: rgba(1, 1, 1, 0.05);
padding: 8px;
border-radius: 8px;
&:not(:last-child) {
margin-bottom: 16px;
}
}
}
:host-context(.dark-theme) {
.tree-node {
color: #5cadd3;
@ -154,5 +192,24 @@
&.highlighted {
background-color: #073d69;
}
.hydration {
color: #60a6fc;
&.skipped {
color: #9a9a9a;;
}
&.mismatched {
color: #ff0040;
}
}
.hydration-error {
color: #ea7171;
pre {
background: rgb(1, 1, 1, 0.2);
}
}
}
}

View file

@ -19,6 +19,7 @@ describe('indexForest', () => {
{
element: 'Parent1',
directives: [],
hydration: null,
component: {
isElement: false,
name: 'Cmp1',
@ -27,6 +28,7 @@ describe('indexForest', () => {
children: [
{
element: 'Child1_1',
hydration: null,
directives: [
{
name: 'Dir1',
@ -43,6 +45,7 @@ describe('indexForest', () => {
{
element: 'Child1_2',
directives: [],
hydration: null,
component: {
isElement: false,
name: 'Cmp2',
@ -56,6 +59,7 @@ describe('indexForest', () => {
element: 'Parent2',
directives: [],
component: null,
hydration: null,
children: [
{
element: 'Child2_1',
@ -65,6 +69,7 @@ describe('indexForest', () => {
id: 1,
},
],
hydration: null,
component: null,
children: [],
},
@ -81,6 +86,7 @@ describe('indexForest', () => {
},
],
component: null,
hydration: null,
children: [],
},
],
@ -91,6 +97,7 @@ describe('indexForest', () => {
element: 'Parent1',
directives: [],
position: [0],
hydration: null,
component: {
isElement: false,
name: 'Cmp1',
@ -111,6 +118,7 @@ describe('indexForest', () => {
},
],
component: null,
hydration: null,
children: [],
},
{
@ -122,6 +130,7 @@ describe('indexForest', () => {
name: 'Cmp2',
id: 1,
},
hydration: null,
children: [],
},
],
@ -131,6 +140,7 @@ describe('indexForest', () => {
directives: [],
component: null,
position: [1],
hydration: null,
children: [
{
element: 'Child2_1',
@ -142,6 +152,7 @@ describe('indexForest', () => {
},
],
component: null,
hydration: null,
children: [],
},
{
@ -159,6 +170,7 @@ describe('indexForest', () => {
],
component: null,
children: [],
hydration: null,
},
],
},

View file

@ -25,7 +25,8 @@ const indexTree = (
component: node.component,
directives: node.directives.map((d, i) => ({name: d.name, id: d.id})),
children: node.children.map((n, i) => indexTree(n, i, position)),
} as IndexedNode;
hydration: node.hydration,
};
};
export const indexForest = (forest: DevToolsNode[]) => forest.map((n, i) => indexTree(n, i));

View file

@ -36,6 +36,7 @@ ts_test_library(
],
deps = [
":property-resolver",
"//devtools/projects/ng-devtools/src/lib/devtools-tabs/directive-explorer/directive-forest/index-forest",
"//devtools/projects/protocol",
"@npm//@angular/cdk",
"@npm//@angular/material",

View file

@ -8,14 +8,17 @@
import {Properties, PropType} from 'protocol';
import {IndexedNode} from '../directive-forest/index-forest';
import {ElementPropertyResolver} from './element-property-resolver';
const mockIndexedNode = {
const mockIndexedNode: IndexedNode = {
component: {
name: 'FooCmp',
id: 0,
isElement: false,
},
hydration: null,
directives: [
{
id: 1,

View file

@ -65,6 +65,7 @@ ts_test_library(
deps = [
":injector_tree_fns",
"//devtools/projects/ng-devtools/src/lib/devtools-tabs/dependency-injection:injector_tree_visualizer",
"//devtools/projects/protocol",
],
)

View file

@ -3,7 +3,7 @@
@if (angularIsInDevMode) {
@if (supportedVersion) {
<div class="devtools-wrapper noselect" [@enterAnimation]>
<ng-devtools-tabs [angularVersion]="angularVersion"></ng-devtools-tabs>
<ng-devtools-tabs [angularVersion]="angularVersion" [isHydrationEnabled]="hydration"/>
</div>
} @else {
<p class="text-message">

View file

@ -34,6 +34,7 @@ export class DevToolsComponent implements OnInit, OnDestroy {
angularExists: boolean | null = null;
angularVersion: string | boolean | undefined = undefined;
angularIsInDevMode = true;
hydration: boolean = false;
ivy!: boolean;
private readonly _firefoxStyleName = 'firefox_styles.css';
@ -56,12 +57,13 @@ export class DevToolsComponent implements OnInit, OnDestroy {
ngOnInit(): void {
this._themeService.initializeThemeWatcher();
this._messageBus.once('ngAvailability', ({version, devMode, ivy}) => {
this._messageBus.once('ngAvailability', ({version, devMode, ivy, hydration}) => {
this.angularExists = !!version;
this.angularVersion = version;
this.angularIsInDevMode = devMode;
this.ivy = ivy;
this._interval$.unsubscribe();
this.hydration = hydration;
});
const browserStyleName = this._platform.FIREFOX

View file

@ -19,6 +19,15 @@ export interface ComponentType {
id: number;
}
export type HydrationStatus =
| null
| {status: 'hydrated' | 'skipped'}
| {
status: 'mismatched';
expectedNodeDetails: string | null;
actualNodeDetails: string | null;
};
export interface DevToolsNode<DirType = DirectiveType, CmpType = ComponentType> {
element: string;
directives: DirType[];
@ -26,6 +35,7 @@ export interface DevToolsNode<DirType = DirectiveType, CmpType = ComponentType>
children: DevToolsNode<DirType, CmpType>[];
nativeElement?: Node;
resolutionPath?: SerializedInjector[];
hydration: HydrationStatus;
}
export interface SerializedInjector {
@ -218,6 +228,7 @@ export interface Events {
version: string | undefined | boolean;
devMode: boolean;
ivy: boolean;
hydration: boolean;
}) => void;
inspectorStart: () => void;
@ -244,6 +255,9 @@ export interface Events {
createHighlightOverlay: (position: ElementPosition) => void;
removeHighlightOverlay: () => void;
createHydrationOverlay: () => void;
removeHydrationOverlay: () => void;
highlightComponent: (id: number) => void;
selectComponent: (id: number) => void;
removeComponentHighlight: () => void;

View file

@ -6,6 +6,8 @@
* found in the LICENSE file at https://angular.io/license
*/
import {ɵHydratedNode as HydrationNode} from '@angular/core';
declare const ng: any;
export const appIsAngularInDevMode = (): boolean => {
@ -56,3 +58,9 @@ export const getAngularVersion = (): string | null => {
}
return el.getAttribute('ng-version');
};
export function isHydrationEnabled(): boolean {
return Array.from(document.querySelectorAll('[ng-version]')).some(
(rootNode) => (rootNode as HydrationNode)?.__ngDebugHydrationInfo__,
);
}

View file

@ -24,7 +24,7 @@
"esModuleInterop": true,
"importHelpers": true,
"target": "es2020",
"lib": ["es2020", "dom"],
"lib": ["es2020", "dom", "dom.iterable"],
"typeRoots": [
"./devtools/node_modules/@types",
],

View file

@ -27,7 +27,7 @@ export {formatRuntimeError as ɵformatRuntimeError, RuntimeError as ɵRuntimeErr
export {annotateForHydration as ɵannotateForHydration} from './hydration/annotate';
export {withDomHydration as ɵwithDomHydration} from './hydration/api';
export {IS_HYDRATION_DOM_REUSE_ENABLED as ɵIS_HYDRATION_DOM_REUSE_ENABLED} from './hydration/tokens';
export {SSR_CONTENT_INTEGRITY_MARKER as ɵSSR_CONTENT_INTEGRITY_MARKER} from './hydration/utils';
export {HydratedNode as ɵHydratedNode, HydrationInfo as ɵHydrationInfo, readHydrationInfo as ɵreadHydrationInfo, SSR_CONTENT_INTEGRITY_MARKER as ɵSSR_CONTENT_INTEGRITY_MARKER} from './hydration/utils';
export {CurrencyIndex as ɵCurrencyIndex, ExtraLocaleDataIndex as ɵExtraLocaleDataIndex, findLocaleData as ɵfindLocaleData, getLocaleCurrencyCode as ɵgetLocaleCurrencyCode, getLocalePluralCase as ɵgetLocalePluralCase, LocaleDataIndex as ɵLocaleDataIndex, registerLocaleData as ɵregisterLocaleData, unregisterAllLocaleData as ɵunregisterLocaleData} from './i18n/locale_data_api';
export {DEFAULT_LOCALE_ID as ɵDEFAULT_LOCALE_ID} from './i18n/localization';
export {Writable as ɵWritable} from './interface/type';

View file

@ -10,8 +10,11 @@ import {RuntimeError, RuntimeErrorCode} from '../errors';
import {getDeclarationComponentDef} from '../render3/instructions/element_validation';
import {TNode, TNodeType} from '../render3/interfaces/node';
import {RNode} from '../render3/interfaces/renderer_dom';
import {LView, TVIEW} from '../render3/interfaces/view';
import {HOST, LView, TVIEW} from '../render3/interfaces/view';
import {getParentRElement} from '../render3/node_manipulation';
import {unwrapRNode} from '../render3/util/view_utils';
import {markRNodeAsHavingHydrationMismatch} from './utils';
const AT_THIS_LOCATION = '<-- AT THIS LOCATION';
@ -48,7 +51,7 @@ function getFriendlyStringFromTNodeType(tNodeType: TNodeType): string {
* Validates that provided nodes match during the hydration process.
*/
export function validateMatchingNode(
node: RNode, nodeType: number, tagName: string|null, lView: LView, tNode: TNode,
node: RNode|null, nodeType: number, tagName: string|null, lView: LView, tNode: TNode,
isViewContainerAnchor = false): void {
if (!node ||
((node as Node).nodeType !== nodeType ||
@ -60,21 +63,25 @@ export function validateMatchingNode(
const hostComponentDef = getDeclarationComponentDef(lView);
const componentClassName = hostComponentDef?.type?.name;
const expected = `Angular expected this DOM:\n\n${
describeExpectedDom(lView, tNode, isViewContainerAnchor)}\n\n`;
const expectedDom = describeExpectedDom(lView, tNode, isViewContainerAnchor);
const expected = `Angular expected this DOM:\n\n${expectedDom}\n\n`;
let actual = '';
if (!node) {
// No node found during hydration.
header += `the node was not found.\n\n`;
// Since the node is missing, we use the closest node to attach the error to
markRNodeAsHavingHydrationMismatch(unwrapRNode(lView[HOST]!), expectedDom);
} else {
const actualNode = shortRNodeDescription(
(node as Node).nodeType, (node as HTMLElement).tagName ?? null,
(node as HTMLElement).textContent ?? null);
header += `found ${actualNode}.\n\n`;
actual = `Actual DOM is:\n\n${describeDomFromNode(node)}\n\n`;
const actualDom = describeDomFromNode(node);
actual = `Actual DOM is:\n\n${actualDom}\n\n`;
markRNodeAsHavingHydrationMismatch(node, expectedDom, actualDom);
}
const footer = getHydrationErrorFooter(componentClassName);
@ -94,6 +101,8 @@ export function validateSiblingNodeExists(node: RNode|null): void {
const footer = getHydrationErrorFooter();
const message = header + actual + footer;
markRNodeAsHavingHydrationMismatch(node!, '', actual);
throw new RuntimeError(RuntimeErrorCode.HYDRATION_MISSING_SIBLINGS, message);
}
}
@ -109,10 +118,15 @@ export function validateNodeExists(
let expected = '';
let footer = '';
if (lView !== null && tNode !== null) {
expected = `${describeExpectedDom(lView, tNode, false)}\n\n`;
expected = describeExpectedDom(lView, tNode, false);
footer = getHydrationErrorFooter();
// Since the node is missing, we use the closest node to attach the error to
markRNodeAsHavingHydrationMismatch(unwrapRNode(lView[HOST]!), expected, '');
}
throw new RuntimeError(RuntimeErrorCode.HYDRATION_MISSING_NODE, header + expected + footer);
throw new RuntimeError(
RuntimeErrorCode.HYDRATION_MISSING_NODE, `${header}${expected}\n\n${footer}`);
}
}
@ -141,6 +155,7 @@ export function nodeNotFoundAtPathError(host: Node, path: string): Error {
`using the "${path}" path, starting from the ${describeRNode(host)} node.\n\n`;
const footer = getHydrationErrorFooter();
markRNodeAsHavingHydrationMismatch(host);
throw new RuntimeError(RuntimeErrorCode.HYDRATION_MISSING_NODE, header + footer);
}

View file

@ -1,4 +1,3 @@
/**
* @license
* Copyright Google LLC All Rights Reserved.
@ -17,7 +16,7 @@ import {HEADER_OFFSET, LView, TVIEW, TViewType} from '../render3/interfaces/view
import {makeStateKey, TransferState} from '../transfer_state';
import {assertDefined} from '../util/assert';
import {CONTAINERS, DehydratedView, DISCONNECTED_NODES, ELEMENT_CONTAINERS, MULTIPLIER, NUM_ROOT_NODES, SerializedContainerView, SerializedView} from './interfaces';
import {CONTAINERS, DehydratedView, DISCONNECTED_NODES, ELEMENT_CONTAINERS, MULTIPLIER, NUM_ROOT_NODES, SerializedContainerView, SerializedView,} from './interfaces';
/**
* The name of the key used in the TransferState collection,
@ -43,7 +42,6 @@ export const NGH_ATTR_NAME = 'ngh';
export const SSR_CONTENT_INTEGRITY_MARKER = 'nghm';
export const enum TextNodeMarker {
/**
* The contents of the text comment added to nodes that would otherwise be
* empty when serialized by the server and passed to the client. The empty
@ -75,7 +73,10 @@ export const enum TextNodeMarker {
let _retrieveHydrationInfoImpl: typeof retrieveHydrationInfoImpl = () => null;
export function retrieveHydrationInfoImpl(
rNode: RElement, injector: Injector, isRootView = false): DehydratedView|null {
rNode: RElement,
injector: Injector,
isRootView = false,
): DehydratedView|null {
let nghAttrValue = rNode.getAttribute(NGH_ATTR_NAME);
if (nghAttrValue == null) return null;
@ -95,7 +96,8 @@ export function retrieveHydrationInfoImpl(
// We've read one of the ngh ids, keep the remaining one, so that
// we can set it back on the DOM element.
const remainingNgh = isRootView ? componentViewNgh : (rootViewNgh ? `|${rootViewNgh}` : '');
const rootNgh = rootViewNgh ? `|${rootViewNgh}` : '';
const remainingNgh = isRootView ? componentViewNgh : rootNgh;
let data: SerializedView = {};
// An element might have an empty `ngh` attribute value (e.g. `<comp ngh="" />`),
@ -167,7 +169,10 @@ export function enableRetrieveHydrationInfoImpl() {
* and accessing a corresponding slot in TransferState storage.
*/
export function retrieveHydrationInfo(
rNode: RElement, injector: Injector, isRootView = false): DehydratedView|null {
rNode: RElement,
injector: Injector,
isRootView = false,
): DehydratedView|null {
return _retrieveHydrationInfoImpl(rNode, injector, isRootView);
}
@ -216,7 +221,7 @@ export function processTextNodeMarkersBeforeHydration(node: HTMLElement) {
const isTextNodeMarker =
content === TextNodeMarker.EmptyNode || content === TextNodeMarker.Separator;
return isTextNodeMarker ? NodeFilter.FILTER_ACCEPT : NodeFilter.FILTER_REJECT;
}
},
});
let currentNode: Comment;
// We cannot modify the DOM while using the commentIterator,
@ -225,7 +230,7 @@ export function processTextNodeMarkersBeforeHydration(node: HTMLElement) {
// applying the changes to the DOM: either inserting an empty node
// or just removing the marker if it was used as a separator.
const nodes = [];
while (currentNode = commentNodesIterator.nextNode() as Comment) {
while ((currentNode = commentNodesIterator.nextNode() as Comment)) {
nodes.push(currentNode);
}
for (const node of nodes) {
@ -241,9 +246,35 @@ export function processTextNodeMarkersBeforeHydration(node: HTMLElement) {
* Internal type that represents a claimed node.
* Only used in dev mode.
*/
type ClaimedNode = {
__claimed?: boolean;
export enum HydrationStatus {
Hydrated = 'hydrated',
Skipped = 'skipped',
Mismatched = 'mismatched',
}
// clang-format off
export type HydrationInfo = {
status: HydrationStatus.Hydrated|HydrationStatus.Skipped;
}|{
status: HydrationStatus.Mismatched;
actualNodeDetails: string|null;
expectedNodeDetails: string|null
};
// clang-format on
const HYDRATION_INFO_KEY = '__ngDebugHydrationInfo__';
export type HydratedNode = {
[HYDRATION_INFO_KEY]?: HydrationInfo;
};
function patchHydrationInfo(node: RNode, info: HydrationInfo) {
(node as HydratedNode)[HYDRATION_INFO_KEY] = info;
}
export function readHydrationInfo(node: RNode): HydrationInfo|null {
return (node as HydratedNode)[HYDRATION_INFO_KEY] ?? null;
}
/**
* Marks a node as "claimed" by hydration process.
@ -254,21 +285,64 @@ export function markRNodeAsClaimedByHydration(node: RNode, checkIfAlreadyClaimed
if (!ngDevMode) {
throw new Error(
'Calling `markRNodeAsClaimedByHydration` in prod mode ' +
'is not supported and likely a mistake.');
'is not supported and likely a mistake.',
);
}
if (checkIfAlreadyClaimed && isRNodeClaimedForHydration(node)) {
throw new Error('Trying to claim a node, which was claimed already.');
}
(node as ClaimedNode).__claimed = true;
patchHydrationInfo(node, {status: HydrationStatus.Hydrated});
ngDevMode.hydratedNodes++;
}
export function markRNodeAsSkippedByHydration(node: RNode) {
if (!ngDevMode) {
throw new Error(
'Calling `markRNodeAsSkippedByHydration` in prod mode ' +
'is not supported and likely a mistake.',
);
}
patchHydrationInfo(node, {status: HydrationStatus.Skipped});
ngDevMode.componentsSkippedHydration++;
}
export function markRNodeAsHavingHydrationMismatch(
node: RNode,
expectedNodeDetails: string|null = null,
actualNodeDetails: string|null = null,
) {
if (!ngDevMode) {
throw new Error(
'Calling `markRNodeAsMismatchedByHydration` in prod mode ' +
'is not supported and likely a mistake.',
);
}
// The RNode can be a standard HTMLElement
// The devtools component tree only displays Angular components & directives
// Therefore we attach the debug info to the closest a claimed node.
while (node && readHydrationInfo(node)?.status !== HydrationStatus.Hydrated) {
node = node?.parentNode as RNode;
}
if (node) {
patchHydrationInfo(node, {
status: HydrationStatus.Mismatched,
expectedNodeDetails,
actualNodeDetails,
});
}
}
export function isRNodeClaimedForHydration(node: RNode): boolean {
return !!(node as ClaimedNode).__claimed;
return readHydrationInfo(node)?.status === HydrationStatus.Hydrated;
}
export function setSegmentHead(
hydrationInfo: DehydratedView, index: number, node: RNode|null): void {
hydrationInfo: DehydratedView,
index: number,
node: RNode|null,
): void {
hydrationInfo.segmentHeads ??= {};
hydrationInfo.segmentHeads[index] = node;
}
@ -298,7 +372,9 @@ export function getNgContainerSize(hydrationInfo: DehydratedView, index: number)
}
export function getSerializedContainerViews(
hydrationInfo: DehydratedView, index: number): SerializedContainerView[]|null {
hydrationInfo: DehydratedView,
index: number,
): SerializedContainerView[]|null {
return hydrationInfo.data[CONTAINERS]?.[index] ?? null;
}
@ -324,7 +400,7 @@ export function isDisconnectedNode(hydrationInfo: DehydratedView, index: number)
// Check if we are processing disconnected info for the first time.
if (typeof hydrationInfo.disconnectedNodes === 'undefined') {
const nodeIds = hydrationInfo.data[DISCONNECTED_NODES];
hydrationInfo.disconnectedNodes = nodeIds ? (new Set(nodeIds)) : null;
hydrationInfo.disconnectedNodes = nodeIds ? new Set(nodeIds) : null;
}
return !!hydrationInfo.disconnectedNodes?.has(index);
}

View file

@ -9,7 +9,7 @@
import {invalidSkipHydrationHost, validateMatchingNode, validateNodeExists} from '../../hydration/error_handling';
import {locateNextRNode} from '../../hydration/node_lookup_utils';
import {hasSkipHydrationAttrOnRElement, hasSkipHydrationAttrOnTNode} from '../../hydration/skip_hydration';
import {getSerializedContainerViews, isDisconnectedNode, markRNodeAsClaimedByHydration, setSegmentHead} from '../../hydration/utils';
import {getSerializedContainerViews, isDisconnectedNode, markRNodeAsClaimedByHydration, markRNodeAsSkippedByHydration, setSegmentHead} from '../../hydration/utils';
import {assertDefined, assertEqual, assertIndexInRange} from '../../util/assert';
import {assertFirstCreatePass, assertHasParent} from '../assert';
import {attachPatchData} from '../context_discovery';
@ -17,7 +17,7 @@ import {registerPostOrderHooks} from '../hooks';
import {hasClassInput, hasStyleInput, TAttributes, TElementNode, TNode, TNodeFlags, TNodeType} from '../interfaces/node';
import {Renderer} from '../interfaces/renderer';
import {RElement} from '../interfaces/renderer_dom';
import {hasI18n, isComponentHost, isContentQueryHost, isDirectiveHost} from '../interfaces/type_checks';
import {isComponentHost, isContentQueryHost, isDirectiveHost} from '../interfaces/type_checks';
import {HEADER_OFFSET, HYDRATION, LView, RENDERER, TView} from '../interfaces/view';
import {assertTNodeType} from '../node_assert';
import {appendChild, clearElementContents, createElementNode, setupStaticAttributes} from '../node_manipulation';
@ -242,7 +242,8 @@ function locateOrCreateElementNodeImpl(
// so there's no duplicate content after render
clearElementContents(native);
ngDevMode && ngDevMode.componentsSkippedHydration++;
ngDevMode && markRNodeAsSkippedByHydration(native);
} else if (ngDevMode) {
// If this is not a component host, throw an error.
// Hydration can be skipped on per-component basis only.

View file

@ -12,7 +12,7 @@ import {CommonModule, DOCUMENT, isPlatformServer, NgComponentOutlet, NgFor, NgIf
import {MockPlatformLocation} from '@angular/common/testing';
import {afterRender, ApplicationRef, Component, ComponentRef, ContentChildren, createComponent, destroyPlatform, Directive, ElementRef, EnvironmentInjector, ErrorHandler, getPlatform, inject, Injectable, Input, NgZone, PLATFORM_ID, Provider, QueryList, TemplateRef, Type, ViewChild, ViewContainerRef, ViewEncapsulation, ɵsetDocument, ɵwhenStable as whenStable} from '@angular/core';
import {Console} from '@angular/core/src/console';
import {SSR_CONTENT_INTEGRITY_MARKER} from '@angular/core/src/hydration/utils';
import {HydrationStatus, readHydrationInfo, SSR_CONTENT_INTEGRITY_MARKER} from '@angular/core/src/hydration/utils';
import {getComponentDef} from '@angular/core/src/render3/definition';
import {NoopNgZone} from '@angular/core/src/zone/ng_zone';
import {TestBed} from '@angular/core/testing';
@ -129,7 +129,7 @@ function verifyAllNodesClaimedForHydration(el: HTMLElement, exceptions: HTMLElem
return;
}
if (!(el as any).__claimed) {
if (readHydrationInfo(el)?.status !== HydrationStatus.Hydrated) {
fail('Hydration error: the node is *not* hydrated: ' + el.outerHTML);
}
verifyAllChildNodesClaimedForHydration(el, exceptions);
@ -150,7 +150,7 @@ function verifyAllChildNodesClaimedForHydration(el: HTMLElement, exceptions: HTM
* hydration feature can be turned off.
*/
function verifyNoNodesWereClaimedForHydration(el: HTMLElement) {
if ((el as any).__claimed) {
if (readHydrationInfo(el)?.status === HydrationStatus.Hydrated) {
fail(
'Unexpected state: the following node was hydrated, when the test ' +
'expects the node to be re-created instead: ' + el.outerHTML);