mirror of
https://github.com/angular/angular
synced 2026-05-24 09:28:37 +00:00
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
269 lines
9.6 KiB
TypeScript
269 lines
9.6 KiB
TypeScript
/**
|
|
* @license
|
|
* Copyright Google LLC All Rights Reserved.
|
|
*
|
|
* Use of this source code is governed by an MIT-style license that can be
|
|
* found in the LICENSE file at https://angular.io/license
|
|
*/
|
|
|
|
import type {ɵGlobalDevModeUtils as GlobalDevModeUtils, Type} from '@angular/core';
|
|
import {HydrationStatus} from 'protocol';
|
|
|
|
let hydrationOverlayItems: HTMLElement[] = [];
|
|
let selectedElementOverlay: HTMLElement | null = null;
|
|
|
|
declare const ng: GlobalDevModeUtils['ng'];
|
|
|
|
const DEV_TOOLS_HIGHLIGHT_NODE_ID = '____ngDevToolsHighlight';
|
|
|
|
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;
|
|
const overlayContent = document.createElement('div');
|
|
overlayContent.style.backgroundColor = toCSSColor(...color, 0.9);
|
|
overlayContent.style.position = 'absolute';
|
|
overlayContent.style.fontFamily = 'monospace';
|
|
overlayContent.style.fontSize = '11px';
|
|
overlayContent.style.padding = '2px 3px';
|
|
overlayContent.style.borderRadius = '3px';
|
|
overlayContent.style.color = 'white';
|
|
overlay.appendChild(overlayContent);
|
|
return {overlay, overlayContent};
|
|
}
|
|
|
|
export function findComponentAndHost(el: Node | undefined): {
|
|
component: any;
|
|
host: HTMLElement | null;
|
|
} {
|
|
if (!el) {
|
|
return {component: null, host: null};
|
|
}
|
|
while (el) {
|
|
const component = el instanceof HTMLElement && ng.getComponent(el);
|
|
if (component) {
|
|
return {component, host: el as HTMLElement};
|
|
}
|
|
if (!el.parentElement) {
|
|
break;
|
|
}
|
|
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 highlightSelectedElement(el: Node): void {
|
|
selectedElementOverlay = addHighlightForElement(el);
|
|
}
|
|
|
|
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 (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;
|
|
}
|
|
const doc = node.ownerDocument.documentElement;
|
|
const parent = node.parentNode;
|
|
return (
|
|
doc === node || doc === parent || !!(parent && parent.nodeType === 1 && doc.contains(parent))
|
|
);
|
|
}
|
|
|
|
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;
|
|
}
|
|
if (!inDoc(el)) {
|
|
return;
|
|
}
|
|
return el.getBoundingClientRect();
|
|
}
|
|
|
|
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(overlayContent, dimensions, labelPosition);
|
|
overlayContent.replaceChildren();
|
|
|
|
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(
|
|
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 = '';
|
|
|
|
// Attempt to position the content element so that it's always in the
|
|
// viewport along the Y axis. Prefer to position on the bottom.
|
|
if (dimensions.bottom + yOffset <= viewportHeight) {
|
|
style.bottom = yOffsetValue;
|
|
// If it doesn't fit on the bottom, try to position on top.
|
|
} else if (dimensions.top - yOffset >= 0) {
|
|
style.top = yOffsetValue;
|
|
// Otherwise offset from the bottom until it fits on the screen.
|
|
} else {
|
|
style.bottom = `${Math.max(dimensions.bottom - viewportHeight, 0)}px`;
|
|
}
|
|
|
|
// Attempt to position the content element so that it's always in the
|
|
// viewport along the X axis. Prefer to position on the right.
|
|
if (dimensions.right <= viewportWidth) {
|
|
style.right = '0';
|
|
// If it doesn't fit on the right, try to position on left.
|
|
} else if (dimensions.left >= 0) {
|
|
style.left = '0';
|
|
// Otherwise offset from the right until it fits on the screen.
|
|
} else {
|
|
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;
|
|
}
|