diff --git a/devtools/projects/ng-devtools/src/lib/shared/signal-graph/devtools-signal-graph.spec.ts b/devtools/projects/ng-devtools/src/lib/shared/signal-graph/devtools-signal-graph.spec.ts index 7f7610d917a7..da7f2a9aa27b 100644 --- a/devtools/projects/ng-devtools/src/lib/shared/signal-graph/devtools-signal-graph.spec.ts +++ b/devtools/projects/ng-devtools/src/lib/shared/signal-graph/devtools-signal-graph.spec.ts @@ -333,4 +333,395 @@ describe('convertToDevtoolsSignalGraph', () => { }, }); }); + + it('should handle cluster-to-cluster dependencies (unidirectional)', () => { + const debugGraph: DebugSignalGraph = { + nodes: [ + { + id: 'a', + kind: 'signal', + epoch: 1, + debuggable: false, + preview: dummyPreview, + label: 'Resource#foo.signalFoo', + }, + { + id: 'b', + kind: 'computed', + epoch: 1, + debuggable: false, + preview: dummyPreview, + label: 'Resource#bar.computedBar', + }, + { + id: 'c', + kind: 'template', + epoch: 1, + debuggable: false, + preview: dummyPreview, + }, + ], + edges: [ + {producer: 0, consumer: 1}, + {producer: 1, consumer: 2}, + ], + }; + const graph = convertToDevtoolsSignalGraph(debugGraph); + + expect(graph).toEqual({ + nodes: [ + { + id: 'a', + kind: 'signal', + epoch: 1, + debuggable: false, + preview: dummyPreview, + label: 'signalFoo', + nodeType: 'signal', + clusterId: 'cl_foo', + }, + { + id: 'b', + kind: 'computed', + epoch: 1, + debuggable: false, + preview: dummyPreview, + label: 'computedBar', + nodeType: 'signal', + clusterId: 'cl_bar', + }, + { + id: 'c', + kind: 'template', + epoch: 1, + debuggable: false, + preview: dummyPreview, + nodeType: 'signal', + clusterId: undefined, + }, + { + id: 'cl_foo', + nodeType: 'cluster', + clusterType: 'resource', + label: 'foo', + previewNode: undefined, + }, + { + id: 'cl_bar', + nodeType: 'cluster', + clusterType: 'resource', + label: 'bar', + previewNode: undefined, + }, + ], + edges: [ + {producer: 0, consumer: 1}, // Pre-existing (signalFoo->computedBar) + {producer: 1, consumer: 2}, // Pre-existing (computedBar->template) + {producer: 3, consumer: 1}, // Cluster-to-signal (foo->computedBar) + {producer: 4, consumer: 2}, // Cluster-to-template (bar->template) + {producer: 0, consumer: 4}, // Signal-to-cluster (signalFoo->bar) + {producer: 3, consumer: 4}, // Cluster-to-cluster (foo->bar) + ], + clusters: { + 'cl_foo': { + id: 'cl_foo', + name: 'foo', + type: 'resource', + }, + 'cl_bar': { + id: 'cl_bar', + name: 'bar', + type: 'resource', + }, + }, + }); + }); + + it('should handle cluster-to-cluster dependencies (multidirectional)', () => { + const debugGraph: DebugSignalGraph = { + nodes: [ + { + id: 'a', + kind: 'signal', + epoch: 1, + debuggable: false, + preview: dummyPreview, + label: 'Resource#foo.signalFoo', + }, + { + id: 'b', + kind: 'computed', + epoch: 1, + debuggable: false, + preview: dummyPreview, + label: 'Resource#foo.computedFoo', + }, + { + id: 'c', + kind: 'computed', + epoch: 1, + debuggable: false, + preview: dummyPreview, + label: 'Resource#bar.computedBar', + }, + { + id: 'd', + kind: 'signal', + epoch: 1, + debuggable: false, + preview: dummyPreview, + label: 'Resource#bar.signalBar', + }, + { + id: 'e', + kind: 'template', + epoch: 1, + debuggable: false, + preview: dummyPreview, + }, + ], + edges: [ + {producer: 0, consumer: 2}, + {producer: 3, consumer: 1}, + {producer: 2, consumer: 4}, + {producer: 1, consumer: 4}, + ], + }; + const graph = convertToDevtoolsSignalGraph(debugGraph); + + expect(graph).toEqual({ + nodes: [ + { + id: 'a', + kind: 'signal', + epoch: 1, + debuggable: false, + preview: dummyPreview, + label: 'signalFoo', + nodeType: 'signal', + clusterId: 'cl_foo', + }, + { + id: 'b', + kind: 'computed', + epoch: 1, + debuggable: false, + preview: dummyPreview, + label: 'computedFoo', + nodeType: 'signal', + clusterId: 'cl_foo', + }, + { + id: 'c', + kind: 'computed', + epoch: 1, + debuggable: false, + preview: dummyPreview, + label: 'computedBar', + nodeType: 'signal', + clusterId: 'cl_bar', + }, + { + id: 'd', + kind: 'signal', + epoch: 1, + debuggable: false, + preview: dummyPreview, + label: 'signalBar', + nodeType: 'signal', + clusterId: 'cl_bar', + }, + { + id: 'e', + kind: 'template', + epoch: 1, + debuggable: false, + preview: dummyPreview, + nodeType: 'signal', + clusterId: undefined, + }, + { + id: 'cl_foo', + nodeType: 'cluster', + clusterType: 'resource', + label: 'foo', + previewNode: undefined, + }, + { + id: 'cl_bar', + nodeType: 'cluster', + clusterType: 'resource', + label: 'bar', + previewNode: undefined, + }, + ], + edges: [ + {producer: 0, consumer: 2}, // Pre-existing (signalFoo->computedBar) + {producer: 3, consumer: 1}, // Pre-existing (signalBar->computedFoo) + {producer: 2, consumer: 4}, // Pre-existing (computedBar->template) + {producer: 1, consumer: 4}, // Pre-existing (computedFoo->template) + {producer: 5, consumer: 2}, // Cluster-to-signal (foo->computedBar) + {producer: 5, consumer: 4}, // Cluster-to-template (foo->template) + {producer: 3, consumer: 5}, // Signal-to-cluster (signalBar->foo) + {producer: 6, consumer: 4}, // Cluster-to-template (bar->template) + {producer: 6, consumer: 1}, // Cluster-to-signal (bar->computedFoo) + {producer: 0, consumer: 6}, // Signal-to-cluster (signalFoo->bar) + {producer: 6, consumer: 5}, // Cluster-to-cluster (bar->foo) + {producer: 5, consumer: 6}, // Cluster-to-cluster (foo->bar) + ], + clusters: { + 'cl_foo': { + id: 'cl_foo', + name: 'foo', + type: 'resource', + }, + 'cl_bar': { + id: 'cl_bar', + name: 'bar', + type: 'resource', + }, + }, + }); + }); + + it('should handle cluster-to-cluster dependencies with one-to-many relationship (1:N)', () => { + const debugGraph: DebugSignalGraph = { + nodes: [ + { + id: 'a', + kind: 'signal', + epoch: 1, + debuggable: false, + preview: dummyPreview, + label: 'Resource#foo.signalFoo', + }, + { + id: 'b', + kind: 'computed', + epoch: 1, + debuggable: false, + preview: dummyPreview, + label: 'Resource#bar.computedBar', + }, + { + id: 'c', + kind: 'computed', + epoch: 1, + debuggable: false, + preview: dummyPreview, + label: 'Resource#baz.computedBaz', + }, + { + id: 'd', + kind: 'template', + epoch: 1, + debuggable: false, + preview: dummyPreview, + }, + ], + edges: [ + {producer: 0, consumer: 1}, + {producer: 0, consumer: 2}, + {producer: 1, consumer: 3}, + {producer: 2, consumer: 3}, + ], + }; + const graph = convertToDevtoolsSignalGraph(debugGraph); + + expect(graph).toEqual({ + nodes: [ + { + id: 'a', + kind: 'signal', + epoch: 1, + debuggable: false, + preview: dummyPreview, + label: 'signalFoo', + nodeType: 'signal', + clusterId: 'cl_foo', + }, + { + id: 'b', + kind: 'computed', + epoch: 1, + debuggable: false, + preview: dummyPreview, + label: 'computedBar', + nodeType: 'signal', + clusterId: 'cl_bar', + }, + { + id: 'c', + kind: 'computed', + epoch: 1, + debuggable: false, + preview: dummyPreview, + label: 'computedBaz', + nodeType: 'signal', + clusterId: 'cl_baz', + }, + { + id: 'd', + kind: 'template', + epoch: 1, + debuggable: false, + preview: dummyPreview, + nodeType: 'signal', + clusterId: undefined, + }, + { + id: 'cl_foo', + nodeType: 'cluster', + clusterType: 'resource', + label: 'foo', + previewNode: undefined, + }, + { + id: 'cl_bar', + nodeType: 'cluster', + clusterType: 'resource', + label: 'bar', + previewNode: undefined, + }, + { + id: 'cl_baz', + nodeType: 'cluster', + clusterType: 'resource', + label: 'baz', + previewNode: undefined, + }, + ], + edges: [ + {producer: 0, consumer: 1}, + {producer: 0, consumer: 2}, + {producer: 1, consumer: 3}, + {producer: 2, consumer: 3}, + {producer: 4, consumer: 1}, // foo->computedBar + {producer: 4, consumer: 2}, // foo->computedBaz + {producer: 5, consumer: 3}, // bar->template + {producer: 0, consumer: 5}, // signalFoo->bar + {producer: 6, consumer: 3}, // baz->template + {producer: 0, consumer: 6}, // signalFoo->baz + {producer: 4, consumer: 5}, // foo->bar + {producer: 4, consumer: 6}, // foo->baz + ], + clusters: { + 'cl_foo': { + id: 'cl_foo', + name: 'foo', + type: 'resource', + }, + 'cl_bar': { + id: 'cl_bar', + name: 'bar', + type: 'resource', + }, + 'cl_baz': { + id: 'cl_baz', + name: 'baz', + type: 'resource', + }, + }, + }); + }); }); diff --git a/devtools/projects/ng-devtools/src/lib/shared/signal-graph/devtools-signal-graph.ts b/devtools/projects/ng-devtools/src/lib/shared/signal-graph/devtools-signal-graph.ts index 348f4cecd9e2..0ae95218248c 100644 --- a/devtools/projects/ng-devtools/src/lib/shared/signal-graph/devtools-signal-graph.ts +++ b/devtools/projects/ng-devtools/src/lib/shared/signal-graph/devtools-signal-graph.ts @@ -12,7 +12,7 @@ import { DevtoolsSignalGraph, DevtoolsSignalGraphNode, } from './signal-graph-types'; -import {checkClusterMatch, ClusterLabelFormatType, getNodeNames} from './utils'; +import {checkClusterMatch, ClusterLabelFormatType, getNodeNames, isSignalNode} from './utils'; interface Cluster { id: string; @@ -29,7 +29,9 @@ const PREVIEW_NODES: {[key in ClusterLabelFormatType]: string | null} = { }; /** - * Returns a cluster identifier based on the label string. + * Identifies clusters within a `DebugSignalGraph` using the node label strings, + * and returns an array of cluster objects (intermediate type) used for + * building the final signal graph with clusters. * Intended for format: #. * * Note: Currently, the only supported type of cluster identifiers. @@ -88,6 +90,57 @@ function identifyClusters(graph: DebugSignalGraph): Cluster[] { return Array.from(clusters.values()); } +/** + * Process cluster-to-cluster dependencies by adding + * the required edges to the provided signal graph in-place. + * + * @param graph Signal graph to be updated (in-place). + * @param clusters Intermediate cluster array. + * @param clusterIdxMap Cluster ID to index (position in `nodes`) map. + */ +function processClusterToClusterDependencies( + graph: DevtoolsSignalGraph, + clusters: Cluster[], + clusterIdxMap: Map, +) { + // Creates a map of consumer index to cluster IDs. + // We don't care about the producers since we are + // interested only in cluster-to-cluster relationships, + // so using either the consumers or the producers works. + const consumerToClustersMap = new Map(); + for (const cluster of clusters) { + for (const consumerIdx of cluster.consumers) { + const existingClusters = consumerToClustersMap.get(consumerIdx); + const clusters = existingClusters ?? []; + clusters.push(cluster.id); + + if (!existingClusters) { + consumerToClustersMap.set(consumerIdx, clusters); + } + } + } + + for (const [i, node] of graph.nodes.entries()) { + if (isSignalNode(node) && node.clusterId) { + const producerClusterIds = consumerToClustersMap.get(i); + if (!producerClusterIds?.length) { + continue; + } + + // Add an edge for all nodes that are part of a cluster + // which are produced by nodes in other clusters. + for (const producerClusterId of producerClusterIds) { + if (node.clusterId !== producerClusterId) { + graph.edges.push({ + producer: clusterIdxMap.get(producerClusterId)!, + consumer: clusterIdxMap.get(node.clusterId)!, + }); + } + } + } + } +} + /** * Convert a `DebugSignalGraph` to a DevTools-FE specific `DevtoolsSignalGraph`. */ @@ -127,6 +180,9 @@ export function convertToDevtoolsSignalGraph( // Set edges signalGraph.edges = [...debugSignalGraph.edges]; + // A map needed for cluster-to-cluster deps processing + const clusterIdxMap = new Map(); + // Add cluster nodes and edges for (const cluster of clusters) { signalGraph.nodes.push({ @@ -136,6 +192,7 @@ export function convertToDevtoolsSignalGraph( label: cluster.name, previewNode: cluster.previewNode, }); + clusterIdxMap.set(cluster.id, signalGraph.nodes.length - 1); // Start from the last node index const clusterIdx = signalGraph.nodes.length - 1; @@ -155,5 +212,8 @@ export function convertToDevtoolsSignalGraph( } } + // We process cluster-to-cluster deps in the end + processClusterToClusterDependencies(signalGraph, clusters, clusterIdxMap); + return signalGraph; }