From cd8203493018e3aac2d00a0a17a538f6cad52bf2 Mon Sep 17 00:00:00 2001 From: hawkgs Date: Thu, 14 May 2026 12:35:44 +0300 Subject: [PATCH 1/2] fix(devtools): cluster-to-cluster relationships in signal graph The PR addresses a missing step in the clustering phase of the signal graph processing on the DevTools frontend. Cluster-to-cluster relationship were missing from the graph, so the change fixes that. --- .../devtools-signal-graph.spec.ts | 250 ++++++++++++++++++ .../signal-graph/devtools-signal-graph.ts | 57 +++- 2 files changed, 305 insertions(+), 2 deletions(-) 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..57d4a4bcc9e7 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,254 @@ 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', + }, + }, + }); + }); }); 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..8003aba7d06a 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,50 @@ 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 ID. + // 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 consumerToClusterMap = new Map(); + for (const cluster of clusters) { + for (const consumerIdx of cluster.consumers) { + consumerToClusterMap.set(consumerIdx, cluster.id); + } + } + + for (let i = 0; i < graph.nodes.length; i++) { + const producerClusterId = consumerToClusterMap.get(i); + const node = graph.nodes[i]; + + // Add an edge for all nodes that are part of a cluster + // which are produced by nodes in other clusters. + if ( + producerClusterId && + isSignalNode(node) && + node.clusterId && + 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 +173,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 +185,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 +205,8 @@ export function convertToDevtoolsSignalGraph( } } + // We process cluster-to-cluster deps in the end + processClusterToClusterDependencies(signalGraph, clusters, clusterIdxMap); + return signalGraph; } From 339e1ac516f507a5745510b4a8ec9550e66d7c4d Mon Sep 17 00:00:00 2001 From: hawkgs Date: Fri, 15 May 2026 12:08:47 +0300 Subject: [PATCH 2/2] fixup! fix(devtools): cluster-to-cluster relationships in signal graph --- .../devtools-signal-graph.spec.ts | 141 ++++++++++++++++++ .../signal-graph/devtools-signal-graph.ts | 43 +++--- 2 files changed, 166 insertions(+), 18 deletions(-) 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 57d4a4bcc9e7..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 @@ -583,4 +583,145 @@ describe('convertToDevtoolsSignalGraph', () => { }, }); }); + + 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 8003aba7d06a..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 @@ -103,33 +103,40 @@ function processClusterToClusterDependencies( clusters: Cluster[], clusterIdxMap: Map, ) { - // Creates a map of consumer index to cluster ID. + // 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 consumerToClusterMap = new Map(); + const consumerToClustersMap = new Map(); for (const cluster of clusters) { for (const consumerIdx of cluster.consumers) { - consumerToClusterMap.set(consumerIdx, cluster.id); + const existingClusters = consumerToClustersMap.get(consumerIdx); + const clusters = existingClusters ?? []; + clusters.push(cluster.id); + + if (!existingClusters) { + consumerToClustersMap.set(consumerIdx, clusters); + } } } - for (let i = 0; i < graph.nodes.length; i++) { - const producerClusterId = consumerToClusterMap.get(i); - const node = graph.nodes[i]; + 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. - if ( - producerClusterId && - isSignalNode(node) && - node.clusterId && - node.clusterId !== producerClusterId - ) { - graph.edges.push({ - producer: clusterIdxMap.get(producerClusterId)!, - consumer: clusterIdxMap.get(node.clusterId)!, - }); + // 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)!, + }); + } + } } } }