Skip to content

Commit 2609a9e

Browse files
authored
Merge pull request #515 from hmcelik/fix/layout-state-leak
fix: scope remembered layout to the input graph instead of module state
2 parents a471bce + f8dddcd commit 2609a9e

2 files changed

Lines changed: 78 additions & 14 deletions

File tree

lib/layout.ts

Lines changed: 21 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -34,8 +34,13 @@ interface EdgeProxyNodeLabel extends Omit<NodeLabel, 'e'> {
3434
e: Edge;
3535
}
3636

37-
let _oldGraph: Graph<GraphLabel, NodeLabel, EdgeLabel> | null = null;
38-
let _rawOldNodes: NodeCollection = null;
37+
interface PreviousLayout {
38+
graph: Graph<GraphLabel, NodeLabel, EdgeLabel>;
39+
rawNodes: NodeCollection;
40+
}
41+
42+
// Remember layout state per input graph so unrelated callers cannot affect each other.
43+
const previousLayouts = new WeakMap<Graph<GraphLabel, NodeLabel, EdgeLabel>, PreviousLayout>();
3944

4045
export function layout(g: Graph<GraphLabel, NodeLabel, EdgeLabel>, opts: LayoutOptions = {}): Graph<GraphLabel, NodeLabel, EdgeLabel> {
4146
recursiveClusterLayout(g, util.notime, opts);
@@ -97,7 +102,7 @@ function recursiveClusterLayout(g: Graph<GraphLabel, NodeLabel, EdgeLabel>, time
97102
recursiveClusterLayout(subgraph, time, opts);
98103
// Run the layout pipeline on the subgraph via a proper layout graph
99104
const subLayoutG = buildLayoutGraph(subgraph);
100-
runLayout(subLayoutG, time, opts);
105+
runLayout(subLayoutG, time, opts, null);
101106
updateInputGraph(subgraph, subLayoutG);
102107
// Compute bounding box for the cluster
103108
let minX = Infinity, minY = Infinity, maxX = -Infinity, maxY = -Infinity;
@@ -235,7 +240,8 @@ function recursiveClusterLayout(g: Graph<GraphLabel, NodeLabel, EdgeLabel>, time
235240

236241
// --- Step 5: run the main layout for the top-level graph ---
237242
const layoutG = buildLayoutGraph(g);
238-
runLayout(layoutG, time, opts);
243+
const result = runLayout(layoutG, time, opts, previousLayouts.get(g) ?? null);
244+
previousLayouts.set(g, result);
239245
updateInputGraph(g, layoutG);
240246

241247
// --- Step 6: remove proxy edges, restore cluster internals, position children ---
@@ -317,15 +323,15 @@ function recursiveClusterLayout(g: Graph<GraphLabel, NodeLabel, EdgeLabel>, time
317323
function runLayout(
318324
g: Graph<GraphLabel, NodeLabel, EdgeLabel>,
319325
time: <T>(name: string, fn: () => T) => T,
320-
opts: LayoutOptions
321-
): void {
322-
if (opts?.useDynamic === false) {
323-
_oldGraph = null;
324-
_rawOldNodes = null;
325-
}
326+
opts: LayoutOptions,
327+
previous: PreviousLayout | null = null
328+
): PreviousLayout {
329+
const dynamic = opts?.useDynamic !== false;
330+
const oldGraph = dynamic ? previous?.graph ?? null : null;
331+
const rawOldNodes = dynamic ? previous?.rawNodes ?? null : null;
326332
time(" makeSpaceForEdgeLabels", () => makeSpaceForEdgeLabels(g));
327333
time(" removeSelfEdges", () => removeSelfEdges(g));
328-
time(" acyclic", () => acyclic.run(g, _oldGraph));
334+
time(" acyclic", () => acyclic.run(g, oldGraph));
329335
time(" nestingGraph.run", () => nestingGraph.run(g));
330336
time(" rank", () => rank(util.asNonCompoundGraph(g)));
331337
time(" injectEdgeLabelProxies", () => injectEdgeLabelProxies(g));
@@ -337,12 +343,12 @@ function runLayout(
337343
time(" normalize.run", () => normalize.run(g));
338344
time(" parentDummyChains", () => parentDummyChains(g));
339345
time(" addBorderSegments", () => addBorderSegments(g));
340-
time(" order", () => order(g, opts, _rawOldNodes));
346+
time(" order", () => order(g, opts, rawOldNodes));
341347
time(" insertSelfEdges", () => insertSelfEdges(g));
342348
time(" adjustCoordinateSystem", () => coordinateSystem.adjust(g));
343349
time(" position", () => position(g, opts.corePath));
344350
time(" positionSelfEdges", () => positionSelfEdges(g));
345-
_rawOldNodes = JSON.parse(JSON.stringify((g as unknown as { _nodes: NodeCollection })._nodes));
351+
const rawNodes: NodeCollection = JSON.parse(JSON.stringify((g as unknown as { _nodes: NodeCollection })._nodes));
346352
time(" removeBorderNodes", () => removeBorderNodes(g));
347353
time(" normalize.undo", () => normalize.undo(g));
348354
time(" fixupEdgeLabelCoords", () => fixupEdgeLabelCoords(g));
@@ -351,7 +357,8 @@ function runLayout(
351357
time(" assignNodeIntersects", () => assignNodeIntersects(g));
352358
time(" reversePoints", () => reversePointsForReversedEdges(g));
353359
time(" acyclic.undo", () => acyclic.undo(g));
354-
_oldGraph = g;
360+
361+
return {graph: g, rawNodes};
355362
}
356363

357364
/*

test/layout-test.ts

Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -296,8 +296,65 @@ describe("layout", () => {
296296
b: {x: 50 + 200 + 75 / 2, y: 200 / 2}
297297
});
298298
});
299+
300+
describe("dynamic layout state", () => {
301+
const graphPairs: Array<[[string, string][], [string, string][]]> = [
302+
[
303+
[["b", "a"]],
304+
[["a", "c"], ["a", "b"], ["c", "b"]]
305+
],
306+
[
307+
[["a", "c"], ["a", "b"], ["c", "b"]],
308+
[["b", "a"]]
309+
]
310+
];
311+
312+
it.each(graphPairs)("does not share state between unrelated graphs", (firstEdges, secondEdges) => {
313+
layout(createGraph(firstEdges));
314+
expect(() => layout(createGraph(secondEdges))).not.toThrow();
315+
});
316+
317+
it("produces stable positions when laying out the same graph repeatedly", () => {
318+
const graph = createGraph([["a", "c"], ["a", "b"], ["c", "b"]]);
319+
320+
layout(graph);
321+
const firstCoordinates = extractCoordinates(graph);
322+
layout(graph);
323+
expect(extractCoordinates(graph)).toEqual(firstCoordinates);
324+
layout(graph);
325+
expect(extractCoordinates(graph)).toEqual(firstCoordinates);
326+
});
327+
328+
it("keeps state isolated when graph layouts are interleaved", () => {
329+
const first = createGraph([["b", "a"]]);
330+
const second = createGraph([["a", "c"], ["a", "b"], ["c", "b"]]);
331+
332+
layout(first);
333+
layout(second);
334+
expect(() => layout(first)).not.toThrow();
335+
expect(() => layout(second)).not.toThrow();
336+
});
337+
338+
it("can disable dynamic layout", () => {
339+
const graph = createGraph([["a", "c"], ["a", "b"], ["c", "b"]]);
340+
341+
expect(() => layout(graph, {useDynamic: false})).not.toThrow();
342+
});
343+
});
299344
});
300345

346+
function createGraph(edges: [string, string][]): Graph {
347+
const graph = new Graph()
348+
.setGraph({})
349+
.setDefaultEdgeLabel(() => ({}));
350+
edges.forEach(([v, w]) => {
351+
graph.setNode(v, {width: 100, height: 100});
352+
graph.setNode(w, {width: 100, height: 100});
353+
graph.setEdge(v, w);
354+
});
355+
return graph;
356+
}
357+
301358
function extractCoordinates(g: Graph) {
302359
const nodes = g.nodes();
303360
return nodes.reduce((acc: { [key: string]: { x: number, y: number } }, v) => {

0 commit comments

Comments
 (0)