diff --git a/cypress/platform/viewer.js b/cypress/platform/viewer.js index da3d2e0da82..0847ad66805 100644 --- a/cypress/platform/viewer.js +++ b/cypress/platform/viewer.js @@ -42,7 +42,7 @@ const contentLoaded = async function () { pos = pos + 7; const graphBase64 = document.location.href.substr(pos); const graphObj = JSON.parse(b64ToUtf8(graphBase64)); - if (graphObj.mermaid && graphObj.mermaid.theme === 'dark') { + if (graphObj.mermaid?.theme === 'dark') { document.body.style.background = '#3f3f3f'; } console.log(graphObj); diff --git a/packages/mermaid/src/diagrams/git/gitGraphAst.ts b/packages/mermaid/src/diagrams/git/gitGraphAst.ts index 0dbc1ecb03b..82cf3b0a2de 100644 --- a/packages/mermaid/src/diagrams/git/gitGraphAst.ts +++ b/packages/mermaid/src/diagrams/git/gitGraphAst.ts @@ -425,7 +425,7 @@ function prettyPrintCommitHistory(commitArr: Commit[]) { } } log.debug(label.join(' ')); - if (commit.parents && commit.parents.length == 2 && commit.parents[0] && commit.parents[1]) { + if (commit.parents?.length == 2 && commit.parents?.[0] && commit.parents?.[1]) { const newCommit = state.records.commits.get(commit.parents[0]); upsert(commitArr, commit, newCommit); if (commit.parents[1]) { diff --git a/packages/mermaid/src/diagrams/sequence/sequenceDb.ts b/packages/mermaid/src/diagrams/sequence/sequenceDb.ts index 3d79b9ea616..049d131d49d 100644 --- a/packages/mermaid/src/diagrams/sequence/sequenceDb.ts +++ b/packages/mermaid/src/diagrams/sequence/sequenceDb.ts @@ -192,7 +192,7 @@ export class SequenceDB implements DiagramDB { old.box = assignedBox; // Don't allow description nulling - if (old && name === old.name && description == null) { + if (old?.name === name && description == null) { return; } } diff --git a/packages/mermaid/src/docs/.vitepress/homepageHeroCopy.ts b/packages/mermaid/src/docs/.vitepress/homepageHeroCopy.ts index 6f8f79d2019..5e3c96b1981 100644 --- a/packages/mermaid/src/docs/.vitepress/homepageHeroCopy.ts +++ b/packages/mermaid/src/docs/.vitepress/homepageHeroCopy.ts @@ -10,7 +10,7 @@ const LOG_PREFIX = '[MMD_DOCS_HERO]'; * Note: index.md frontmatter is static, so this is applied via VitePress transformPageData. */ export function applyHomePageHeroCopy(pageData: any, hostname: string): void { - if (!pageData || pageData.relativePath !== 'index.md') { + if (pageData?.relativePath !== 'index.md') { return; } diff --git a/packages/mermaid/src/docs/.vitepress/theme/mermaid.ts b/packages/mermaid/src/docs/.vitepress/theme/mermaid.ts index 4b44f20b46f..0190991c766 100644 --- a/packages/mermaid/src/docs/.vitepress/theme/mermaid.ts +++ b/packages/mermaid/src/docs/.vitepress/theme/mermaid.ts @@ -4,9 +4,9 @@ import tidyTreeLayout from '../../../../../mermaid-layout-tidy-tree/dist/mermaid import layouts from '../../../../../mermaid-layout-elk/dist/mermaid-layout-elk.core.mjs'; const init = Promise.all([ - mermaid.registerExternalDiagrams([zenuml]), - mermaid.registerLayoutLoaders(layouts), - mermaid.registerLayoutLoaders(tidyTreeLayout), + Promise.resolve(mermaid.registerExternalDiagrams([zenuml])), + Promise.resolve(mermaid.registerLayoutLoaders(layouts)), + Promise.resolve(mermaid.registerLayoutLoaders(tidyTreeLayout)), ]); mermaid.registerIconPacks([ { diff --git a/packages/mermaid/src/rendering-util/layout-algorithms/dagre/index.js b/packages/mermaid/src/rendering-util/layout-algorithms/dagre/index.js index e9f0266e263..ea798c0b15a 100644 --- a/packages/mermaid/src/rendering-util/layout-algorithms/dagre/index.js +++ b/packages/mermaid/src/rendering-util/layout-algorithms/dagre/index.js @@ -164,6 +164,15 @@ const recursiveRender = async (_elem, graph, diagramType, id, parentCluster, sit dagreLayout(graph); + // Strip out any virtual ordering edges that were injected for edge-less + // clusters (see mermaid-graphlib.js). They must be removed before rendering + // so that no SVG edge path is drawn for them. + graph.edges().forEach(function (e) { + if (graph.edge(e)?._virtual) { + graph.removeEdge(e.v, e.w, e.name); + } + }); + log.info('Graph after layout:', JSON.stringify(graphlibJson.write(graph))); // Move the nodes to the correct place let diff = 0; diff --git a/packages/mermaid/src/rendering-util/layout-algorithms/dagre/mermaid-graphlib.js b/packages/mermaid/src/rendering-util/layout-algorithms/dagre/mermaid-graphlib.js index f0e5cd5ed9b..8281d5bc2f8 100644 --- a/packages/mermaid/src/rendering-util/layout-algorithms/dagre/mermaid-graphlib.js +++ b/packages/mermaid/src/rendering-util/layout-algorithms/dagre/mermaid-graphlib.js @@ -274,12 +274,16 @@ export const adjustClustersAndEdges = (graph, depth) => { graph.removeEdge(e.v, e.w, e.name); if (v !== e.v) { const parent = graph.parent(v); - clusterDb.get(parent).externalConnections = true; + if (parent && clusterDb.has(parent)) { + clusterDb.get(parent).externalConnections = true; + } edge.fromCluster = e.v; } if (w !== e.w) { const parent = graph.parent(w); - clusterDb.get(parent).externalConnections = true; + if (parent && clusterDb.has(parent)) { + clusterDb.get(parent).externalConnections = true; + } edge.toCluster = e.w; } log.warn('Fix Replacing with XXX', v, w, e.name); @@ -359,11 +363,35 @@ export const extractor = (graph, depth) => { log.warn('Old graph before copy', graphlibJson.write(graph)); copy(node, graph, clusterGraph, node); + + // When a subgraph cluster that has an explicitly declared direction + // contains no edges, Dagre places all nodes in a single rank and lays + // them out along the cross-axis of that rankdir (e.g. `direction LR` + // produces a vertical column). To honour the user's declared direction + // we inject temporary, layout-only ordering edges (A → B → C → D …) that + // force Dagre to distribute the nodes across separate ranks. The edges + // are marked with `_virtual: true` so they can be stripped out in the + // rendering phase before any SVG paths are drawn. + const clusterMeta = clusterDb.get(node); + if (clusterMeta?.clusterData?.dir && clusterGraph.edges().length === 0) { + const clusterNodes = clusterGraph.nodes(); + log.warn('Cluster has no edges, adding virtual ordering edges for layout', clusterNodes); + for (let i = 0; i < clusterNodes.length - 1; i++) { + clusterGraph.setEdge(clusterNodes[i], clusterNodes[i + 1], { + _virtual: true, + weight: 0, + minlen: 1, + }); + } + } + + const existingNodeData = graph.node(node) || {}; graph.setNode(node, { + ...existingNodeData, clusterNode: true, id: node, - clusterData: clusterDb.get(node).clusterData, label: clusterDb.get(node).label, + clusterData: clusterDb.get(node).clusterData, graph: clusterGraph, }); log.warn('New graph after copy node: (', node, ')', graphlibJson.write(clusterGraph)); diff --git a/packages/mermaid/src/rendering-util/layout-algorithms/dagre/mermaid-graphlib.spec.js b/packages/mermaid/src/rendering-util/layout-algorithms/dagre/mermaid-graphlib.spec.js index 11acd44eb18..c2a498a34f3 100644 --- a/packages/mermaid/src/rendering-util/layout-algorithms/dagre/mermaid-graphlib.spec.js +++ b/packages/mermaid/src/rendering-util/layout-algorithms/dagre/mermaid-graphlib.spec.js @@ -400,6 +400,88 @@ flowchart TB expect(aGraph.parent('B')).toBe(undefined); }); }); + +describe('Virtual ordering edges for edge-less clusters', () => { + let g; + beforeEach(function () { + setLogLevel(1); + g = new graphlib.Graph({ + multigraph: true, + compound: true, + }); + g.setDefaultEdgeLabel(function () { + return {}; + }); + }); + + // Build a graph where cluster Y has no external edges (so it gets extracted + // into a clusterNode) and no internal edges (so virtual ordering edges are injected). + // The outer node X has no edge to Y; only Y's children are present. + const makeEdgelessCluster = (rankdir) => { + g.setGraph({ + rankdir, + nodesep: 50, + ranksep: 50, + marginx: 8, + marginy: 8, + }); + g.setNode('A', { data: 1 }); + g.setNode('B', { data: 2 }); + g.setNode('C', { data: 3 }); + g.setNode('D', { data: 4 }); + g.setNode('Y', { data: 5, dir: rankdir }); + g.setParent('A', 'Y'); + g.setParent('B', 'Y'); + g.setParent('C', 'Y'); + g.setParent('D', 'Y'); + // NO edges to/from Y — ensures externalConnections stays false + // so Y gets extracted as a clusterNode + }; + + for (const dir of ['TB', 'BT', 'LR', 'RL']) { + it(`should inject virtual ordering edges for an edge-less cluster with direction ${dir}`, () => { + makeEdgelessCluster(dir); + adjustClustersAndEdges(g); + + const yClusterNode = g.node('Y'); + expect(yClusterNode).toBeDefined(); + expect(yClusterNode.clusterNode).toBe(true); + + const yGraph = yClusterNode.graph; + expect(yGraph.nodes().length).toBe(4); + + // There should be exactly 3 virtual ordering edges (A→B, B→C, C→D) + const edges = yGraph.edges(); + expect(edges.length).toBe(3); + edges.forEach((e) => { + expect(yGraph.edge(e)._virtual).toBe(true); + }); + }); + } + + it('should NOT inject virtual edges when the cluster already has internal edges', () => { + g.setGraph({ rankdir: 'LR', nodesep: 50, ranksep: 50, marginx: 8, marginy: 8 }); + g.setNode('A', { data: 1 }); + g.setNode('B', { data: 2 }); + g.setNode('Y', { data: 3, dir: 'LR' }); + g.setParent('A', 'Y'); + g.setParent('B', 'Y'); + // One real internal edge — should prevent virtual edge injection + g.setEdge('A', 'B', { data: 'real' }); + + adjustClustersAndEdges(g); + + const yClusterNode = g.node('Y'); + expect(yClusterNode?.clusterNode).toBe(true); + + const yGraph = yClusterNode.graph; + const edges = yGraph.edges(); + expect(edges.length).toBe(1); + expect(yGraph.edge(edges[0]).data).toBe('real'); + // No _virtual marker on the real edge + expect(yGraph.edge(edges[0])._virtual).toBeUndefined(); + }); +}); describe('extractDescendants', function () { let g; beforeEach(function () { diff --git a/vite.config.ts b/vite.config.ts index f86711e9ab2..b73b0cfe25a 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -15,6 +15,7 @@ export default defineConfig({ test: { environment: 'jsdom', globals: true, + testTimeout: 30000, // TODO: should we move this to a mermaid-core package? coverage: { provider: 'v8',