Skip to content

Graph layout state leaks between applications via a module-level Dagre graph #355

Description

@nicolejms

Steps to reproduce

  1. Open one application's graph.
  2. Navigate to a second application with a different topology and open its graph.

Observed behavior

The second graph is laid out against state left behind by the first. Node positions can differ from the positions the same application produces when it is the first graph rendered in the session.

The cause is a single module-level Dagre graph in packages/rad-components/src/components/appgraph/AppGraph.tsx:

const g = new Dagre.graphlib.Graph().setDefaultEdgeLabel(() => ({}));

export function getLayoutedElements(nodes, edges, options) {
  g.setGraph({ rankdir: options.direction });

  edges.forEach(edge => g.setEdge(edge.source, edge.target));
  nodes.forEach(node => g.setNode(node.id, node as NodeLabel));

  Dagre.layout(g);
  // ...
}

g is created once per module, never reset, and only ever added to. Nodes and edges from every previously laid-out graph remain in it for the lifetime of the page. setNode and setEdge overwrite matching keys but never remove stale ones, so the layout being solved is the union of every graph rendered so far, not the graph that was passed in.

Desired behavior

getLayoutedElements should construct a fresh Dagre graph per call, or clear the existing one, so that layout is a pure function of its arguments. Laying out the same graph twice, in any order relative to other graphs, should produce identical positions.

Workaround

A full page reload before viewing each graph.

Additional context

Pinned by GU-08 in packages/rad-components/src/__test__/graphInvariants.test.ts, added in nicolejms#1, tagged KNOWN-DEFECT.

One note for whoever writes the regression test after fixing this. The leaked state lives in a module-level binding, so the first layout performed in a test file pollutes every later one and there is no clean baseline left to compare against. A naive "lay out A then B, compare against B alone" test passes while the defect is present — it is a false negative, because the "B alone" measurement is itself already polluted. GU-08 uses jest.isolateModules to obtain a genuinely fresh module per sequence, which is what makes the difference observable.

Found while building the graph invariant suite ahead of the graph rearchitecture, tracked in docs/design/2026-09-dashboard-plugin-test-plan.md.

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    bugtriagedThis item has been triaged by project maintainers and is in the backlog

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions