Skip to content

Commit 87b020d

Browse files
feat(core): implement getSignalGraph debug API
Creates a debug api that returns two arrays of nodes and edges that represent a signal graph in the context of a particular injector.
1 parent ef5240b commit 87b020d

File tree

6 files changed

+292
-6
lines changed

6 files changed

+292
-6
lines changed

packages/core/src/render3/debug/framework_injector_profiler.ts

Lines changed: 20 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -67,6 +67,7 @@ class DIDebugData {
6767
WeakMap<Type<unknown>, InjectedService[]>
6868
>();
6969
resolverToProviders = new WeakMap<Injector | TNode, ProviderRecord[]>();
70+
resolverToEffects = new WeakMap<Injector | LView, any>();
7071
standaloneInjectorToComponent = new WeakMap<Injector, Type<unknown>>();
7172

7273
reset() {
@@ -113,6 +114,24 @@ function handleInjectorProfilerEvent(injectorProfilerEvent: InjectorProfilerEven
113114
handleInstanceCreatedByInjectorEvent(context, injectorProfilerEvent.instance);
114115
} else if (type === InjectorProfilerEventType.ProviderConfigured) {
115116
handleProviderConfiguredEvent(context, injectorProfilerEvent.providerRecord);
117+
} else if (type === InjectorProfilerEventType.EffectCreated) {
118+
handleEffectCreatedEvent(context, injectorProfilerEvent.effect);
119+
}
120+
}
121+
122+
function handleEffectCreatedEvent(context: InjectorProfilerContext, effect: EffectRef): void {
123+
const diResolver = getDIResolver(context.injector);
124+
if (diResolver === null) {
125+
throwError('An EffectCreated event must be run within an injection context.');
126+
}
127+
128+
const diResolverToEffects = frameworkDIDebugData.resolverToEffects;
129+
if (!diResolverToEffects.has(diResolver)) {
130+
diResolverToEffects.set(diResolver, []);
131+
}
132+
133+
if (diResolverToEffects.has(diResolver)) {
134+
diResolverToEffects.get(diResolver).push(effect);
116135
}
117136
}
118137

@@ -280,7 +299,7 @@ function handleProviderConfiguredEvent(
280299
resolverToProviders.get(diResolver)!.push(data);
281300
}
282301

283-
function getDIResolver(injector: Injector | undefined): Injector | LView | null {
302+
export function getDIResolver(injector: Injector | undefined): Injector | LView | null {
284303
let diResolver: Injector | LView | null = null;
285304

286305
if (injector === undefined) {

packages/core/src/render3/debug/injector_profiler.ts

Lines changed: 23 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,11 @@ export const enum InjectorProfilerEventType {
3535
* Emits when an injector configures a provider.
3636
*/
3737
ProviderConfigured,
38+
39+
/**
40+
* Emits when an effect is created.
41+
*/
42+
EffectCreated,
3843
}
3944

4045
/**
@@ -74,14 +79,21 @@ export interface ProviderConfiguredEvent {
7479
providerRecord: ProviderRecord;
7580
}
7681

82+
export interface EffectCreatedEvent {
83+
type: InjectorProfilerEventType.EffectCreated;
84+
context: InjectorProfilerContext;
85+
effect: unknown;
86+
}
87+
7788
/**
7889
* An object representing an event that is emitted through the injector profiler
7990
*/
8091

8192
export type InjectorProfilerEvent =
8293
| InjectedServiceEvent
8394
| InjectorCreatedInstanceEvent
84-
| ProviderConfiguredEvent;
95+
| ProviderConfiguredEvent
96+
| EffectCreatedEvent;
8597

8698
/**
8799
* An object that contains information about a provider that has been configured
@@ -272,6 +284,16 @@ export function emitInjectEvent(token: Type<unknown>, value: unknown, flags: Inj
272284
});
273285
}
274286

287+
export function emitEffectCreatedEvent(effect: unknown): void {
288+
!ngDevMode && throwError('Injector profiler should never be called in production mode');
289+
290+
injectorProfiler({
291+
type: InjectorProfilerEventType.EffectCreated,
292+
context: getInjectorProfilerContext(),
293+
effect,
294+
});
295+
}
296+
275297
export function runInInjectorProfilerContext(
276298
injector: Injector,
277299
token: Type<unknown>,

packages/core/src/render3/reactivity/effect.ts

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@ import {FLAGS, LViewFlags, EFFECTS_TO_SCHEDULE} from '../interfaces/view';
2222
import {assertNotInReactiveContext} from './asserts';
2323
import {performanceMarkFeature} from '../../util/performance';
2424
import {PendingTasks} from '../../pending_tasks';
25+
import {emitEffectCreatedEvent, setInjectorProfilerContext} from '../debug/injector_profiler';
2526

2627
/**
2728
* An effect can, optionally, register a cleanup function. If registered, the cleanup is executed
@@ -303,5 +304,14 @@ export function effect(
303304
(cdr._lView[EFFECTS_TO_SCHEDULE] ??= []).push(handle.watcher.notify);
304305
}
305306

307+
if (ngDevMode) {
308+
const prevInjectorProfilerContext = setInjectorProfilerContext({injector, token: null});
309+
try {
310+
emitEffectCreatedEvent(handle);
311+
} finally {
312+
setInjectorProfilerContext(prevInjectorProfilerContext);
313+
}
314+
}
315+
306316
return handle;
307317
}

packages/core/src/render3/util/discovery_utils.ts

Lines changed: 190 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -6,10 +6,12 @@
66
* found in the LICENSE file at https://angular.io/license
77
*/
88

9+
import {ComputedNode, SIGNAL, SignalNode} from '@angular/core/primitives/signals';
910
import {ChangeDetectionStrategy} from '../../change_detection/constants';
1011
import {Injector} from '../../di/injector';
1112
import {ViewEncapsulation} from '../../metadata/view';
12-
import {assertLView} from '../assert';
13+
import {throwError} from '../../util/assert';
14+
import {assertLView, assertTNode} from '../assert';
1315
import {
1416
discoverLocalRefs,
1517
getComponentAtNodeIndex,
@@ -18,13 +20,28 @@ import {
1820
readPatchedLView,
1921
} from '../context_discovery';
2022
import {getComponentDef, getDirectiveDef} from '../definition';
21-
import {NodeInjector} from '../di';
23+
import {NodeInjector, getNodeInjectorLView, getNodeInjectorTNode} from '../di';
2224
import {DirectiveDef} from '../interfaces/definition';
2325
import {TElementNode, TNode, TNodeProviderIndexes} from '../interfaces/node';
24-
import {CLEANUP, CONTEXT, FLAGS, LView, LViewFlags, TVIEW, TViewType} from '../interfaces/view';
26+
import {
27+
CLEANUP,
28+
CONTEXT,
29+
FLAGS,
30+
HOST,
31+
LView,
32+
LViewFlags,
33+
REACTIVE_TEMPLATE_CONSUMER,
34+
TVIEW,
35+
TViewType,
36+
} from '../interfaces/view';
2537

2638
import {getRootContext} from './view_traversal_utils';
2739
import {getLViewParent, unwrapRNode} from './view_utils';
40+
import {isLView} from '../interfaces/type_checks';
41+
import {getFrameworkDIDebugData} from '../debug/framework_injector_profiler';
42+
import {Watch, WatchNode} from '@angular/core/primitives/signals/src/watch';
43+
import {R3Injector} from '../../di/r3_injector';
44+
import {ReactiveLViewConsumer} from '../reactive_lview_consumer';
2845

2946
/**
3047
* Retrieves the component instance associated with a given DOM element.
@@ -513,3 +530,173 @@ function extractInputDebugMetadata<T>(inputs: DirectiveDef<T>['inputs']) {
513530

514531
return res;
515532
}
533+
534+
type SignalGraphNode<T> = SignalNode<T> | ComputedNode<T> | WatchNode | ReactiveLViewConsumer;
535+
536+
interface DebugSignalNode<T> {
537+
type: 'signal';
538+
label: string;
539+
value: T;
540+
}
541+
interface DebugEffectNode {
542+
type: 'effect';
543+
label: string;
544+
}
545+
546+
interface DebugComputedNode<T> {
547+
type: 'computed';
548+
label: string;
549+
value: T;
550+
}
551+
552+
interface DebugTemplateNode {
553+
type: 'template';
554+
label: string;
555+
}
556+
557+
type DebugSignalGraphNode<T> =
558+
| DebugSignalNode<T>
559+
| DebugEffectNode
560+
| DebugComputedNode<T>
561+
| DebugTemplateNode;
562+
563+
interface DebugSignalGraphEdge {
564+
from: number;
565+
to: number;
566+
}
567+
568+
interface DebugSignalGraph<T> {
569+
nodes: DebugSignalGraphNode<T>[];
570+
edges: DebugSignalGraphEdge[];
571+
}
572+
573+
function isComputedNode<T>(node: SignalGraphNode<T>): node is ComputedNode<T> {
574+
return (node as ComputedNode<T>).computation !== undefined;
575+
}
576+
577+
function isTemplateNode<T>(node: SignalGraphNode<T>): node is ReactiveLViewConsumer {
578+
return (
579+
(node as ReactiveLViewConsumer).lView !== undefined &&
580+
isLView((node as ReactiveLViewConsumer).lView)
581+
);
582+
}
583+
584+
function isEffectNode<T>(node: SignalGraphNode<T>): node is WatchNode {
585+
return (node as WatchNode).cleanupFn !== undefined;
586+
}
587+
588+
export function getSignalGraph(injector: Injector): DebugSignalGraph<unknown> {
589+
if (!(injector instanceof NodeInjector) && !(injector instanceof R3Injector)) {
590+
return throwError('getSignals must be called with a NodeInjector or an R3Injector');
591+
}
592+
593+
const signalDependenciesMap = new Map<SignalGraphNode<unknown>, Set<SignalGraphNode<unknown>>>();
594+
595+
// if the injector is a NodeInjector, we need to extract the signals from the template
596+
// otherwise if it is an R3Injector, we proceed as normal without this extra step since both cases
597+
// require us to extract signals from the injector
598+
if (injector instanceof NodeInjector) {
599+
const tNode = getNodeInjectorTNode(injector)!;
600+
const lView = getNodeInjectorLView(injector);
601+
602+
assertTNode(tNode);
603+
assertLView(lView);
604+
const templateLView = lView[tNode.index];
605+
if (templateLView) {
606+
const templateConsumer = templateLView[REACTIVE_TEMPLATE_CONSUMER];
607+
608+
if (templateConsumer) {
609+
extractSignalNodesAndEdgesFromRoot(templateConsumer, signalDependenciesMap);
610+
}
611+
}
612+
}
613+
614+
const effects = extractEffectsFromInjector(injector);
615+
for (const effect of effects) {
616+
const {watcher} = effect;
617+
const signalRoot = watcher[SIGNAL];
618+
extractSignalNodesAndEdgesFromRoot(signalRoot, signalDependenciesMap);
619+
}
620+
621+
return extractNodesAndEdgesFromSignalMap(signalDependenciesMap);
622+
}
623+
624+
function extractNodesAndEdgesFromSignalMap(
625+
signalMap: Map<SignalGraphNode<unknown>, Set<SignalGraphNode<unknown>>>,
626+
): {
627+
nodes: DebugSignalGraphNode<unknown>[];
628+
edges: DebugSignalGraphEdge[];
629+
} {
630+
const nodes = Array.from(signalMap.keys());
631+
const debugSignalGraphNodes = nodes.map((signalGraphNode: SignalGraphNode<unknown>) => {
632+
if (isComputedNode(signalGraphNode)) {
633+
return {
634+
label: signalGraphNode.debugName,
635+
value: signalGraphNode.value,
636+
type: 'computed',
637+
};
638+
}
639+
640+
if (isTemplateNode(signalGraphNode)) {
641+
return {
642+
label: signalGraphNode.lView?.[HOST]?.tagName?.toLowerCase?.(),
643+
value: undefined,
644+
type: 'template',
645+
};
646+
}
647+
648+
if (isEffectNode(signalGraphNode)) {
649+
return {
650+
label: signalGraphNode.debugName,
651+
value: undefined,
652+
type: 'effect',
653+
};
654+
}
655+
656+
return {
657+
label: signalGraphNode.debugName,
658+
value: signalGraphNode.value,
659+
type: 'signal',
660+
};
661+
}) as DebugSignalGraphNode<unknown>[];
662+
663+
const edges: DebugSignalGraphEdge[] = [];
664+
for (const [node, producers] of signalMap.entries()) {
665+
for (const producer of producers) {
666+
edges.push({from: nodes.indexOf(node), to: nodes.indexOf(producer)});
667+
}
668+
}
669+
670+
return {nodes: debugSignalGraphNodes, edges};
671+
}
672+
673+
function extractEffectsFromInjector(injector: Injector) {
674+
let diResolver: Injector | LView<unknown> = injector;
675+
if (injector instanceof NodeInjector) {
676+
const lView = getNodeInjectorLView(injector)!;
677+
diResolver = lView;
678+
}
679+
680+
const {resolverToEffects} = getFrameworkDIDebugData();
681+
return resolverToEffects.get(diResolver) ?? [];
682+
}
683+
684+
function extractSignalNodesAndEdgesFromRoot(
685+
node: SignalGraphNode<unknown>,
686+
signalDependenciesMap: Map<SignalGraphNode<unknown>, Set<SignalGraphNode<unknown>>>,
687+
): Map<SignalGraphNode<unknown>, Set<SignalGraphNode<unknown>>> {
688+
if (signalDependenciesMap.has(node)) {
689+
return signalDependenciesMap;
690+
}
691+
692+
signalDependenciesMap.set(node, new Set());
693+
694+
const {producerNode} = node;
695+
696+
for (const producer of producerNode ?? []) {
697+
signalDependenciesMap.get(node)!.add(producer as SignalNode<unknown>);
698+
extractSignalNodesAndEdgesFromRoot(producer as SignalNode<unknown>, signalDependenciesMap);
699+
}
700+
701+
return signalDependenciesMap;
702+
}

packages/core/src/render3/util/global_utils.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@ import {
2222
getListeners,
2323
getOwningComponent,
2424
getRootComponents,
25+
getSignalGraph,
2526
} from './discovery_utils';
2627
import {
2728
getDependenciesFromInjectable,
@@ -58,6 +59,7 @@ const globalUtilsFunctions = {
5859
'ɵgetInjectorResolutionPath': getInjectorResolutionPath,
5960
'ɵgetInjectorMetadata': getInjectorMetadata,
6061
'ɵsetProfiler': setProfiler,
62+
'ɵgetSignalGraph': getSignalGraph,
6163

6264
'getDirectiveMetadata': getDirectiveMetadata,
6365
'getComponent': getComponent,

0 commit comments

Comments
 (0)