angular/devtools/projects/ng-devtools-backend/src/lib/client-event-subscribers.ts
AleksanderBodurri ebcdc8dc96 refactor(devtools): implement multiframe support in devtools page (#53934)
In the Angular DevTools Chrome DevTools page:

- Angular DevTools is able to ask the background script to list each frame that has been registered on a page.
- Angular Devtools is able to ask the background script to "enable" the connection on a particular frame. This enables the messaging between the content script <-> background script <-> devtools page
- Implements detection of non unique urls on the inspected page

Limitations:
- The `inspectedWindow.eval` API is only able to target frames by frameURL. This means some features that integrate with Chrome DevTools like inspect element and open source will not be available when inspecting frames that do not have a unique url on the page.

PR Close #53934
2024-02-14 17:15:25 -08:00

434 lines
14 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 {
ComponentExplorerViewQuery,
ComponentType,
DevToolsNode,
DirectivePosition,
DirectiveType,
ElementPosition,
Events,
MessageBus,
ProfilerFrame,
SerializedInjector,
SerializedProviderRecord,
} from 'protocol';
import {debounceTime} from 'rxjs/operators';
import {
appIsAngularInDevMode,
appIsAngularIvy,
appIsSupportedAngularVersion,
getAngularVersion,
isHydrationEnabled,
} from 'shared-utils';
import {ComponentInspector} from './component-inspector/component-inspector';
import {
getElementInjectorElement,
getInjectorFromElementNode,
getInjectorProviders,
getInjectorResolutionPath,
getLatestComponentState,
idToInjector,
injectorsSeen,
isElementInjector,
nodeInjectorToResolutionPath,
queryDirectiveForest,
serializeProviderRecord,
serializeResolutionPath,
updateState,
} from './component-tree';
import {unHighlight} from './highlighter';
import {disableTimingAPI, enableTimingAPI, initializeOrGetDirectiveForestHooks} from './hooks';
import {start as startProfiling, stop as stopProfiling} from './hooks/capture';
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 {runOutsideAngular} from './utils';
import {DirectiveForestHooks} from './hooks/hooks';
export const subscribeToClientEvents = (
messageBus: MessageBus<Events>,
depsForTestOnly?: {
directiveForestHooks?: typeof DirectiveForestHooks;
},
): void => {
messageBus.on('shutdown', shutdownCallback(messageBus));
messageBus.on(
'getLatestComponentExplorerView',
getLatestComponentExplorerViewCallback(messageBus),
);
messageBus.on('queryNgAvailability', checkForAngularCallback(messageBus));
messageBus.on('startProfiling', startProfilingCallback(messageBus));
messageBus.on('stopProfiling', stopProfilingCallback(messageBus));
messageBus.on('setSelectedComponent', selectedComponentCallback);
messageBus.on('getNestedProperties', getNestedPropertiesCallback(messageBus));
messageBus.on('getRoutes', getRoutesCallback(messageBus));
messageBus.on('updateState', updateState);
messageBus.on('enableTimingAPI', enableTimingAPI);
messageBus.on('disableTimingAPI', disableTimingAPI);
messageBus.on('getInjectorProviders', getInjectorProvidersCallback(messageBus));
messageBus.on('logProvider', logProvider);
messageBus.on('log', ({message, level}) => {
console[level](`[Angular DevTools]: ${message}`);
});
if (appIsAngularInDevMode() && appIsSupportedAngularVersion() && appIsAngularIvy()) {
setupInspector(messageBus);
// Often websites have `scroll` event listener which triggers
// Angular's change detection. We don't want to constantly send
// update requests, instead we want to request an update at most
// once every 250ms
runOutsideAngular(() => {
initializeOrGetDirectiveForestHooks(depsForTestOnly)
.profiler.changeDetection$.pipe(debounceTime(250))
.subscribe(() => messageBus.emit('componentTreeDirty'));
});
}
};
//
// Callback Definitions
//
const shutdownCallback = (messageBus: MessageBus<Events>) => () => {
messageBus.destroy();
};
const getLatestComponentExplorerViewCallback =
(messageBus: MessageBus<Events>) => (query?: ComponentExplorerViewQuery) => {
// We want to force re-indexing of the component tree.
// Pressing the refresh button means the user saw stuck UI.
initializeOrGetDirectiveForestHooks().indexForest();
const forest = prepareForestForSerialization(
initializeOrGetDirectiveForestHooks().getIndexedDirectiveForest(),
ngDebugDependencyInjectionApiIsSupported(),
);
// cleanup injector id mappings
for (const injectorId of idToInjector.keys()) {
if (!injectorsSeen.has(injectorId)) {
const injector = idToInjector.get(injectorId)!;
if (isElementInjector(injector)) {
const element = getElementInjectorElement(injector);
if (element) {
nodeInjectorToResolutionPath.delete(element);
}
}
idToInjector.delete(injectorId);
}
}
injectorsSeen.clear();
if (!query) {
messageBus.emit('latestComponentExplorerView', [{forest}]);
return;
}
const state = getLatestComponentState(
query,
initializeOrGetDirectiveForestHooks().getDirectiveForest(),
);
if (state) {
const {directiveProperties} = state;
messageBus.emit('latestComponentExplorerView', [{forest, properties: directiveProperties}]);
}
};
const checkForAngularCallback = (messageBus: MessageBus<Events>) => () =>
checkForAngular(messageBus);
const getRoutesCallback = (messageBus: MessageBus<Events>) => () => getRoutes(messageBus);
const startProfilingCallback = (messageBus: MessageBus<Events>) => () =>
startProfiling((frame: ProfilerFrame) => {
messageBus.emit('sendProfilerChunk', [frame]);
});
const stopProfilingCallback = (messageBus: MessageBus<Events>) => () => {
messageBus.emit('profilerResults', [stopProfiling()]);
};
const selectedComponentCallback = (position: ElementPosition) => {
const node = queryDirectiveForest(
position,
initializeOrGetDirectiveForestHooks().getIndexedDirectiveForest(),
);
setConsoleReference({node, position});
};
const getNestedPropertiesCallback =
(messageBus: MessageBus<Events>) => (position: DirectivePosition, propPath: string[]) => {
const emitEmpty = () => messageBus.emit('nestedProperties', [position, {props: {}}, propPath]);
const node = queryDirectiveForest(
position.element,
initializeOrGetDirectiveForestHooks().getIndexedDirectiveForest(),
);
if (!node) {
return emitEmpty();
}
const current =
position.directive === undefined ? node.component : node.directives[position.directive];
if (!current) {
return emitEmpty();
}
let data = current.instance;
for (const prop of propPath) {
data = data[prop];
if (!data) {
console.error('Cannot access the properties', propPath, 'of', node);
}
}
messageBus.emit('nestedProperties', [
position,
{props: serializeDirectiveState(data)},
propPath,
]);
return;
};
//
// Subscribe Helpers
//
// todo: parse router tree with framework APIs after they are developed
const getRoutes = (messageBus: MessageBus<Events>) => {
// Return empty router tree to disable tab.
messageBus.emit('updateRouterTree', [[]]);
};
const checkForAngular = (messageBus: MessageBus<Events>): void => {
const ngVersion = getAngularVersion();
const appIsIvy = appIsAngularIvy();
if (!ngVersion) {
return;
}
if (appIsIvy && appIsAngularInDevMode() && appIsSupportedAngularVersion()) {
initializeOrGetDirectiveForestHooks();
}
messageBus.emit('ngAvailability', [
{
version: ngVersion.toString(),
devMode: appIsAngularInDevMode(),
ivy: appIsIvy,
hydration: isHydrationEnabled(),
},
]);
};
const setupInspector = (messageBus: MessageBus<Events>) => {
const inspector = new ComponentInspector({
onComponentEnter: (id: number) => {
messageBus.emit('highlightComponent', [id]);
},
onComponentLeave: () => {
messageBus.emit('removeComponentHighlight');
},
onComponentSelect: (id: number) => {
messageBus.emit('selectComponent', [id]);
},
});
messageBus.on('inspectorStart', inspector.startInspecting);
messageBus.on('inspectorEnd', inspector.stopInspecting);
messageBus.on('createHighlightOverlay', (position: ElementPosition) => {
inspector.highlightByPosition(position);
});
messageBus.on('removeHighlightOverlay', unHighlight);
messageBus.on('createHydrationOverlay', inspector.highlightHydrationNodes);
messageBus.on('removeHydrationOverlay', inspector.removeHydrationHighlights);
};
export interface SerializableDirectiveInstanceType extends DirectiveType {
id: number;
}
export interface SerializableComponentInstanceType extends ComponentType {
id: number;
}
export interface SerializableComponentTreeNode
extends DevToolsNode<SerializableDirectiveInstanceType, SerializableComponentInstanceType> {
children: SerializableComponentTreeNode[];
}
// Here we drop properties to prepare the tree for serialization.
// We don't need the component instance, so we just traverse the tree
// and leave the component name.
const prepareForestForSerialization = (
roots: ComponentTreeNode[],
includeResolutionPath = false,
): SerializableComponentTreeNode[] => {
const serializedNodes: SerializableComponentTreeNode[] = [];
for (const node of roots) {
const serializedNode: SerializableComponentTreeNode = {
element: node.element,
component: node.component
? {
name: node.component.name,
isElement: node.component.isElement,
id: initializeOrGetDirectiveForestHooks().getDirectiveId(node.component.instance)!,
}
: null,
directives: node.directives.map((d) => ({
name: d.name,
id: initializeOrGetDirectiveForestHooks().getDirectiveId(d.instance)!,
})),
children: prepareForestForSerialization(node.children, includeResolutionPath),
hydration: node.hydration,
};
serializedNodes.push(serializedNode);
if (includeResolutionPath) {
serializedNode.resolutionPath = getNodeDIResolutionPath(node);
}
}
return serializedNodes;
};
function getNodeDIResolutionPath(node: ComponentTreeNode): SerializedInjector[] | undefined {
const nodeInjector = getInjectorFromElementNode(node.nativeElement!);
if (!nodeInjector) {
return [];
}
// There are legit cases where an angular node will have non-ElementInjector injectors.
// For example, components created with createComponent require the API consumer to
// pass in an element injector, else it sets the element injector of the component
// to the NullInjector
if (!isElementInjector(nodeInjector)) {
return [];
}
const element = getElementInjectorElement(nodeInjector);
if (!nodeInjectorToResolutionPath.has(element)) {
const resolutionPaths = getInjectorResolutionPath(nodeInjector);
nodeInjectorToResolutionPath.set(element, serializeResolutionPath(resolutionPaths));
}
const serializedPath = nodeInjectorToResolutionPath.get(element)!;
for (const injector of serializedPath) {
injectorsSeen.add(injector.id);
}
return serializedPath;
}
const getInjectorProvidersCallback =
(messageBus: MessageBus<Events>) => (injector: SerializedInjector) => {
if (!idToInjector.has(injector.id)) {
return;
}
const providerRecords = getInjectorProviders(idToInjector.get(injector.id)!);
const allProviderRecords: SerializedProviderRecord[] = [];
const tokenToRecords: Map<any, SerializedProviderRecord[]> = new Map();
for (const [index, providerRecord] of providerRecords.entries()) {
const record = serializeProviderRecord(
providerRecord,
index,
injector.type === 'environment',
);
allProviderRecords.push(record);
const records = tokenToRecords.get(providerRecord.token) ?? [];
records.push(record);
tokenToRecords.set(providerRecord.token, records);
}
const serializedProviderRecords: SerializedProviderRecord[] = [];
for (const [token, records] of tokenToRecords.entries()) {
const multiRecords = records.filter((record) => record.multi);
const nonMultiRecords = records.filter((record) => !record.multi);
for (const record of nonMultiRecords) {
serializedProviderRecords.push(record);
}
const [firstMultiRecord] = multiRecords;
if (firstMultiRecord !== undefined) {
// All multi providers will have the same token, so we can just use the first one.
serializedProviderRecords.push({
token: firstMultiRecord.token,
type: 'multi',
multi: true,
// todo(aleksanderbodurri): implememnt way to differentiate multi providers that
// provided as viewProviders
isViewProvider: firstMultiRecord.isViewProvider,
index: records.map((record) => record.index as number),
});
}
}
messageBus.emit('latestInjectorProviders', [injector, serializedProviderRecords]);
};
const logProvider = (
serializedInjector: SerializedInjector,
serializedProvider: SerializedProviderRecord,
): void => {
if (!idToInjector.has(serializedInjector.id)) {
return;
}
const injector = idToInjector.get(serializedInjector.id)!;
const providerRecords = getInjectorProviders(injector);
console.group(
`%c${serializedInjector.name}`,
`color: ${
serializedInjector.type === 'element' ? '#a7d5a9' : '#f05057'
}; font-size: 1.25rem; font-weight: bold;`,
);
// tslint:disable-next-line:no-console
console.log('injector: ', injector);
if (typeof serializedProvider.index === 'number') {
const provider = providerRecords[serializedProvider.index];
// tslint:disable-next-line:no-console
console.log('provider: ', provider);
// tslint:disable-next-line:no-console
console.log(`value: `, injector.get(provider.token, null, {optional: true}));
} else if (Array.isArray(serializedProvider.index)) {
const providers = serializedProvider.index.map((index) => providerRecords[index]);
// tslint:disable-next-line:no-console
console.log('providers: ', providers);
// tslint:disable-next-line:no-console
console.log(`value: `, injector.get(providers[0].token, null, {optional: true}));
}
console.groupEnd();
};