Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
23 commits
Select commit Hold shift + click to select a range
cdeedd1
fix(flowchart): normalize TD to TB for subgraph direction (fixes #4648)
sjackson0109 Apr 26, 2026
415af35
fix(flowchart): respect direction for subgraphs with external connect…
sjackson0109 Apr 26, 2026
122ecf5
test(flowchart): add visual test for subgraph direction with external…
sjackson0109 Apr 26, 2026
0b6b302
repack
sjackson0109 Apr 26, 2026
ade3045
[autofix.ci] apply automated fixes
autofix-ci[bot] Apr 26, 2026
4887e97
chore: add changeset, regression test, and Cypress tests for subgraph…
sjackson0109 May 1, 2026
e4c206c
fix(flowchart): add dagre-d3-es patch and fix mermaid-graphlib predic…
sjackson0109 May 1, 2026
5c10842
Merge branch 'develop' into fix/4648-directions
sjackson0109 May 1, 2026
c934e6e
fix(flowchart): restore correct copy(node,...) call pattern in extrac…
sjackson0109 May 2, 2026
7d15390
test(flowchart): add unit tests for explicit-direction branch in extr…
sjackson0109 May 2, 2026
a3130c7
fix(flowchart): rebind cross-boundary edges in copy() to prevent orph…
sjackson0109 May 2, 2026
a114d47
fix(state): only propagate dir to nodeData when explicitly set by user
sjackson0109 May 2, 2026
a819c8b
fix(flowchart,state): use explicitDir flag instead of dir to gate Bra…
sjackson0109 May 2, 2026
d20bf50
[autofix.ci] apply automated fixes
autofix-ci[bot] May 2, 2026
fc6ce97
fix(flowchart): address PR review round-2 blockers and important issues
sjackson0109 May 6, 2026
c9ce128
fix: remove dead dagre patch and fix explicitDir false-positive with …
Copilot May 7, 2026
e3a3344
Merge branch 'develop' into fix/4648-directions
sjackson0109 May 7, 2026
e0d7e32
fix: preserve raw dir value on subGraph, normalize TD→TB only at dagr…
Copilot May 7, 2026
d0754db
refactor: rename rawDir to userDir for clarity
Copilot May 7, 2026
4e8fee0
revert: undo userDir rename, restore rawDir variable name
Copilot May 7, 2026
90cf76f
Merge pull request #2 from sjackson0109/copilot/add-direction-keyword…
sjackson0109 May 7, 2026
8eccac1
Merge branch 'develop' into fix/4648-directions
sjackson0109 May 10, 2026
d619b19
Merge branch 'develop' into fix/4648-directions
sjackson0109 May 12, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
30 changes: 30 additions & 0 deletions .changeset/fix-4648-subgraph-direction.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
---
'mermaid': patch
---

fix(flowchart): respect per-subgraph direction keyword in Dagre layout

Subgraphs with an explicit `direction` keyword (e.g. `direction LR`) now
trigger a separate cluster sub-layout, restoring the intended visual direction
for that group. Previously the cluster-extraction predicate used
`!externalConnections`, which caused subgraphs with external edges to silently
inherit the top-level `rankdir` even when the user had specified a different
direction.

**Behavior change:**

- **Old:** A separate cluster graph was created for any subgraph that had
children and no external connections (`!externalConnections && hasChildren`).
Subgraphs with external edges always inherited the parent direction.
- **New:** A separate cluster graph is created only when the user has
explicitly set a `direction` keyword on the subgraph
(`clusterData?.explicitDir && hasChildren`). Subgraphs without an explicit
`direction` now inherit the parent `rankdir` (previously they defaulted to
the opposite of the parent direction).

Also normalises `direction TD` to `direction TB` inside `addSubGraph` to match
the existing top-level normalisation in `setDirection`, fixing the
"`direction TD` doesn't work inside subgraphs" complaint in #4648.

Fixes #4648
See also #6785
49 changes: 49 additions & 0 deletions cypress/integration/rendering/flowchart/flowchart-v2.spec.js
Original file line number Diff line number Diff line change
Expand Up @@ -351,6 +351,55 @@ end
{ htmlLabels: true, flowchart: { htmlLabels: true }, securityLevel: 'loose' }
);
});
it('issue-4648: sibling subgraphs with different directions and external connections', () => {
imgSnapshotTest(
`flowchart TD

subgraph Group1
direction TB
A1 --> A2
A2 --> A3
end

subgraph Group2
direction LR
B1 --> B2
B2 --> B3
end

subgraph Group3
direction LR
C1 --> C2
C2 --> C3
end

%% External connections between subgraphs
A3 --- B1
B3 --- C1
`,
{ htmlLabels: true, flowchart: { htmlLabels: true }, securityLevel: 'loose' }
);
});

it('issue-4648: nested subgraph with external connection', () => {
imgSnapshotTest(
`flowchart TD

subgraph Wrapper
direction LR
subgraph Inner
D1 --> D2
D2 --> D3
end
end

%% External connection to nested subgraph
D3 --- E1
`,
{ htmlLabels: true, flowchart: { htmlLabels: true }, securityLevel: 'loose' }
);
});

it('57.x: handle nested subgraphs with outgoing links 5', () => {
imgSnapshotTest(
`%% this does not produce the desired result
Expand Down
69 changes: 69 additions & 0 deletions cypress/platform/regression/issue-4648.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,69 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<title>Mermaid Subgraph Direction Test (Issue #4648)</title>
<script type="module" src="/demos/render-mermaid.js"></script>
<style>
body {
font-family: sans-serif;
margin: 2em;
}
.mermaid {
background: #fff;
border: 1px solid #ccc;
margin: 2em 0;
padding: 1em;
}
</style>
</head>
<body>
<h1>Mermaid Subgraph Direction Test (Issue #4648)</h1>
<p>This page demonstrates various subgraph direction scenarios for regression testing.</p>

<h2>Sibling Subgraphs with Different Directions</h2>
<pre class="mermaid">
flowchart TD

subgraph Group1
direction TB
A1 --> A2
A2 --> A3
end

subgraph Group2
direction LR
B1 --> B2
B2 --> B3
end

subgraph Group3
direction LR
C1 --> C2
C2 --> C3
end

%% External connections between subgraphs
A3 --- B1
B3 --- C1
</pre
>

<h2>Nested Subgraph with External Connections</h2>
<pre class="mermaid">
flowchart TD

subgraph Wrapper
direction LR
subgraph Inner
D1 --> D2
D2 --> D3
end
end

%% External connection to nested subgraph
D3 --- E1
</pre
>
</body>
</html>
10 changes: 5 additions & 5 deletions docs/config/setup/mermaid/interfaces/LayoutData.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@

# Interface: LayoutData

Defined in: [packages/mermaid/src/rendering-util/types.ts:169](https://github.com/mermaid-js/mermaid/blob/master/packages/mermaid/src/rendering-util/types.ts#L169)
Defined in: [packages/mermaid/src/rendering-util/types.ts:170](https://github.com/mermaid-js/mermaid/blob/master/packages/mermaid/src/rendering-util/types.ts#L170)

## Indexable

Expand All @@ -22,28 +22,28 @@ Defined in: [packages/mermaid/src/rendering-util/types.ts:169](https://github.co

> **config**: [`MermaidConfig`](MermaidConfig.md)

Defined in: [packages/mermaid/src/rendering-util/types.ts:172](https://github.com/mermaid-js/mermaid/blob/master/packages/mermaid/src/rendering-util/types.ts#L172)
Defined in: [packages/mermaid/src/rendering-util/types.ts:173](https://github.com/mermaid-js/mermaid/blob/master/packages/mermaid/src/rendering-util/types.ts#L173)

---

### diagramId?

> `optional` **diagramId**: `string`

Defined in: [packages/mermaid/src/rendering-util/types.ts:173](https://github.com/mermaid-js/mermaid/blob/master/packages/mermaid/src/rendering-util/types.ts#L173)
Defined in: [packages/mermaid/src/rendering-util/types.ts:174](https://github.com/mermaid-js/mermaid/blob/master/packages/mermaid/src/rendering-util/types.ts#L174)

---

### edges

> **edges**: `Edge`\[]

Defined in: [packages/mermaid/src/rendering-util/types.ts:171](https://github.com/mermaid-js/mermaid/blob/master/packages/mermaid/src/rendering-util/types.ts#L171)
Defined in: [packages/mermaid/src/rendering-util/types.ts:172](https://github.com/mermaid-js/mermaid/blob/master/packages/mermaid/src/rendering-util/types.ts#L172)

---

### nodes

> **nodes**: `Node`\[]

Defined in: [packages/mermaid/src/rendering-util/types.ts:170](https://github.com/mermaid-js/mermaid/blob/master/packages/mermaid/src/rendering-util/types.ts#L170)
Defined in: [packages/mermaid/src/rendering-util/types.ts:171](https://github.com/mermaid-js/mermaid/blob/master/packages/mermaid/src/rendering-util/types.ts#L171)
18 changes: 14 additions & 4 deletions packages/mermaid/src/diagrams/flowchart/flowDb.ts
Original file line number Diff line number Diff line change
Expand Up @@ -699,10 +699,18 @@ You have to call mermaid.initialize.`

const result = uniq(list.flat());
const nodeList = result.nodeList;
let dir = result.dir;
// Preserve the raw user-authored direction value (e.g. 'TD') on the subGraph
// object so that tests and callers see what the user actually wrote.
// Normalization to dagre's canonical 'TB' happens in getData() when the dir
// is consumed by the layout engine.
const rawDir = result.dir;
// Capture whether the user explicitly wrote a direction keyword BEFORE any
// inheritDir override, so that explicitDir is true only for user-authored
// direction statements.
const hasExplicitDir = rawDir !== undefined;
const flowchartConfig = getConfig().flowchart ?? {};
dir =
dir ??
const dir =
rawDir ??
(flowchartConfig.inheritDir
? (this.getDirection() ?? (getConfig() as any).direction ?? undefined)
: undefined);
Expand All @@ -724,6 +732,7 @@ You have to call mermaid.initialize.`
title: title.trim(),
classes: [],
dir,
hasExplicitDir,
labelType: this.sanitizeNodeLabelType(_title?.type),
};

Expand Down Expand Up @@ -1117,7 +1126,8 @@ You have to call mermaid.initialize.`
cssCompiledStyles: this.getCompiledStyles(subGraph.classes),
cssClasses: subGraph.classes.join(' '),
shape: 'rect',
dir: subGraph.dir,
dir: subGraph.dir === 'TD' ? 'TB' : subGraph.dir, // normalize TD→TB for dagre
explicitDir: subGraph.hasExplicitDir, // true only when the user wrote an explicit 'direction X' keyword
isGroup: true,
look: config.look,
});
Expand Down
1 change: 1 addition & 0 deletions packages/mermaid/src/diagrams/flowchart/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -78,6 +78,7 @@ export interface FlowClass {
export interface FlowSubGraph {
classes: string[];
dir?: string;
hasExplicitDir: boolean;
id: string;
labelType: string;
nodes: string[];
Expand Down
5 changes: 5 additions & 0 deletions packages/mermaid/src/diagrams/state/dataFetcher.ts
Original file line number Diff line number Diff line change
Expand Up @@ -271,6 +271,11 @@ export const dataFetcher = (
newNode.type = 'group';
newNode.isGroup = true;
newNode.dir = getDir(parsedItem);
// Set explicitDir only when the user actually wrote a 'direction X' keyword
// inside this state body. mermaid-graphlib's Branch 1 checks explicitDir (not dir)
// so that state compound states without an explicit direction follow the original
// !externalConnections extraction path, while keeping dir for Branch 2's direction arithmetic.
newNode.explicitDir = parsedItem.doc.some((s) => s.stmt === 'dir');
newNode.shape = parsedItem.type === DIVIDER_TYPE ? SHAPE_DIVIDER : SHAPE_GROUP;
newNode.cssClasses = `${newNode.cssClasses} ${CSS_DIAGRAM_CLUSTER} ${altFlag ? CSS_DIAGRAM_CLUSTER_ALT : ''}`;
}
Expand Down
1 change: 1 addition & 0 deletions packages/mermaid/src/diagrams/state/stateDb.ts
Original file line number Diff line number Diff line change
Expand Up @@ -150,6 +150,7 @@ export interface NodeData {
cssStyles: string[];
id: string;
dir?: string;
explicitDir?: boolean; // true only when the user wrote an explicit 'direction X' keyword
domId?: string;
type?: string;
isGroup?: boolean;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -92,9 +92,30 @@ const copy = (clusterId, graph, newGraph, rootId) => {
log.info('Edge data', data, rootId);
try {
if (edgeInCluster(edge, rootId)) {
log.info('Copying as ', edge.v, edge.w, data, edge.name);
newGraph.setEdge(edge.v, edge.w, data, edge.name);
log.info('newGraph edges ', newGraph.edges(), newGraph.edge(newGraph.edges()[0]));
// Determine whether BOTH endpoints are strictly inside the cluster.
// edgeInCluster uses OR logic (either endpoint inside), so a
// cross-boundary edge (one endpoint outside rootId) also passes.
// Copying such an edge into newGraph would auto-create the external
// node as an orphan with no layout data, crashing the renderer.
// Instead, rebind cross-boundary edges in the outer graph as
// rootId → externalNode
// so the connection is preserved after the leaf is removed.
const rootDescendants = descendants.get(rootId) || [];
const vIn =
rootDescendants.includes(edge.v) || isDescendant(edge.v, rootId) || edge.v === rootId;
const wIn =
rootDescendants.includes(edge.w) || isDescendant(edge.w, rootId) || edge.w === rootId;
if (vIn && wIn) {
log.info('Copying as ', edge.v, edge.w, data, edge.name);
newGraph.setEdge(edge.v, edge.w, data, edge.name);
log.info('newGraph edges ', newGraph.edges(), newGraph.edge(newGraph.edges()[0]));
} else {
// Cross-boundary: rebind to the cluster root in the outer graph.
const newV = vIn ? rootId : edge.v;
const newW = wIn ? rootId : edge.w;
log.info('Rebinding cross-boundary edge as ', newV, newW, data, edge.name);
graph.setEdge(newV, newW, data, edge.name);
}
} else {
log.info(
'Skipping copy of edge ',
Expand Down Expand Up @@ -340,11 +361,54 @@ export const extractor = (graph, depth) => {
);
if (!clusterDb.has(node)) {
log.debug('Not a cluster', node, depth);
} else if (
clusterDb.get(node)?.clusterData?.explicitDir &&
graph.children(node) &&
graph.children(node).length > 0
) {
// Cluster with an explicit direction keyword — always create a subgraph,
// even when it has external connections (fixes issue #4648).
log.warn('Cluster with explicit dir, creating subgraph for children', node, depth);

const dir = clusterDb.get(node).clusterData.dir;
const clusterGraph = new graphlib.Graph({
multigraph: true,
compound: true,
})
.setGraph({
rankdir: dir,
nodesep: 50,
ranksep: 50,
marginx: 8,
marginy: 8,
})
.setDefaultEdgeLabel(function () {
return {};
});

// Copy the cluster (and any nested sub-clusters) into the subgraph
copy(node, graph, clusterGraph, node);
// Attach the subgraph to the cluster node for internal layout
const clusterNodeData = graph.node(node) || {};
graph.setNode(node, {
...clusterNodeData,
clusterNode: true,
id: node,
clusterData: clusterDb.get(node).clusterData,
label: clusterDb.get(node).label,
graph: clusterGraph,
});
log.warn(
'Subgraph for cluster with explicit dir created:',
node,
graphlibJson.write(clusterGraph)
);
} else if (
!clusterDb.get(node).externalConnections &&
graph.children(node) &&
graph.children(node).length > 0
) {
// Original behaviour: cluster without external connections gets its own sub-graph.
log.warn(
'Cluster without external connections, without a parent and with children',
node,
Expand Down Expand Up @@ -373,16 +437,16 @@ export const extractor = (graph, depth) => {
return {};
});

log.warn('Old graph before copy', graphlibJson.write(graph));
copy(node, graph, clusterGraph, node);
const clusterNodeData = graph.node(node) || {};
graph.setNode(node, {
...clusterNodeData,
clusterNode: true,
id: node,
clusterData: clusterDb.get(node).clusterData,
label: clusterDb.get(node).label,
graph: clusterGraph,
});
log.warn('New graph after copy node: (', node, ')', graphlibJson.write(clusterGraph));
log.debug('Old graph after copy', graphlibJson.write(graph));
} else {
log.warn(
Expand Down
Loading
Loading