mirror of
https://github.com/angular/angular
synced 2026-05-24 09:28:37 +00:00
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:
parent
274a489bb5
commit
b560e02cdf
40 changed files with 1347 additions and 129 deletions
|
|
@ -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"],
|
||||
|
|
|
|||
|
|
@ -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 = () => {};
|
||||
}
|
||||
|
|
@ -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);
|
||||
|
||||
|
|
|
|||
|
|
@ -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());
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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 [];
|
||||
});
|
||||
}
|
||||
|
|
|
|||
|
|
@ -63,7 +63,7 @@ export function getInjectorId() {
|
|||
return `${injectorId++}`;
|
||||
}
|
||||
|
||||
function getInjectorMetadata(injector: Injector) {
|
||||
export function getInjectorMetadata(injector: Injector) {
|
||||
return ngDebugClient().ɵgetInjectorMetadata(injector);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
],
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
};
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -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()) {
|
||||
|
|
|
|||
|
|
@ -31,6 +31,7 @@
|
|||
<div class="tab-content">
|
||||
<ng-directive-explorer
|
||||
[showCommentNodes]="showCommentNodes"
|
||||
[isHydrationEnabled]="isHydrationEnabled"
|
||||
[class.hidden]="activeTab !== 'Components'"
|
||||
(toggleInspector)="toggleInspector()"
|
||||
/>
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
],
|
||||
)
|
||||
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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'),
|
||||
},
|
||||
],
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
},
|
||||
],
|
||||
},
|
||||
|
|
|
|||
|
|
@ -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));
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
],
|
||||
)
|
||||
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load diff
|
|
@ -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">
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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__,
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -24,7 +24,7 @@
|
|||
"esModuleInterop": true,
|
||||
"importHelpers": true,
|
||||
"target": "es2020",
|
||||
"lib": ["es2020", "dom"],
|
||||
"lib": ["es2020", "dom", "dom.iterable"],
|
||||
"typeRoots": [
|
||||
"./devtools/node_modules/@types",
|
||||
],
|
||||
|
|
|
|||
|
|
@ -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';
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
Loading…
Reference in a new issue