/** * @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, 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) => () => { messageBus.destroy(); }; const getLatestComponentExplorerViewCallback = (messageBus: MessageBus) => (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) => () => checkForAngular(messageBus); const getRoutesCallback = (messageBus: MessageBus) => () => getRoutes(messageBus); const startProfilingCallback = (messageBus: MessageBus) => () => startProfiling((frame: ProfilerFrame) => { messageBus.emit('sendProfilerChunk', [frame]); }); const stopProfilingCallback = (messageBus: MessageBus) => () => { messageBus.emit('profilerResults', [stopProfiling()]); }; const selectedComponentCallback = (position: ElementPosition) => { const node = queryDirectiveForest( position, initializeOrGetDirectiveForestHooks().getIndexedDirectiveForest(), ); setConsoleReference({node, position}); }; const getNestedPropertiesCallback = (messageBus: MessageBus) => (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) => { // Return empty router tree to disable tab. messageBus.emit('updateRouterTree', [[]]); }; const checkForAngular = (messageBus: MessageBus): 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) => { 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 { 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) => (injector: SerializedInjector) => { if (!idToInjector.has(injector.id)) { return; } const providerRecords = getInjectorProviders(idToInjector.get(injector.id)!); const allProviderRecords: SerializedProviderRecord[] = []; const tokenToRecords: Map = 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(); };