Root detection in grid layout is order-dependent, misclassifying child nodes as roots
Bug
createMapping misidentifies root nodes when edges defining child → parent relationships appear after parent → grandchild edges in the mermaid source. This causes all misidentified roots to be placed on the same grid level, producing a flat horizontal layout instead of the expected hierarchical tree.
Reproduction
import { renderMermaidASCII } from 'beautiful-mermaid';
const result = renderMermaidASCII(`graph TD
A["Parent A"] --> C["Child C"]
B["Parent B"] --> C
C --> D["Grandchild D"]
A1["Root 1"] --> A
A2["Root 2"] --> A
A3["Root 3"] --> A
B1["Root 4"] --> B
B2["Root 5"] --> B`);
console.log(result);Expected: A1, A2, A3, B1, B2 at the top level; A, B one level below; C, D below that.
Actual: A, B, A1, A2, A3, B1, B2 are all placed on the same row (level 0), producing a flat horizontal strip instead of a tree.
Analysis
The root detection in src/ascii/grid.ts:398-410:
const nodesFound = new Set<string>()
const initialRoots: AsciiNode[] = []
for (const node of graph.nodes) {
if (!nodesFound.has(node.name)) {
initialRoots.push(node)
}
nodesFound.add(node.name)
for (const child of getChildren(graph, node)) {
nodesFound.add(child.name)
}
}The comment says "Identify root nodes -- nodes that aren't the target of any edge", but the implementation does not check all edge targets. It performs a single forward pass over graph.nodes (in Map insertion order from the parser) and marks a node as root if it hasn't been seen as a child yet.
The parser inserts nodes into the Map in the order they first appear in edges. So for:
A --> C ← A inserted first
...
A1 --> A ← A1 inserted after AWhen the loop reaches node A, A1 → A hasn't been processed yet, so A has never been seen as anyone's child. A is incorrectly added to initialRoots.
The second filter (line 415-429) only removes subgraph nodes with external incoming edges — it does not catch this case for non-subgraph nodes.
Suggested fix
Replace the order-dependent heuristic with a direct edge-target check:
// Collect all nodes that are the target of at least one edge
const edgeTargets = new Set(graph.edges.map(e => e.to.name))
// Root nodes are those that never appear as an edge target
const rootNodes = graph.nodes.filter(n => !edgeTargets.has(n.name))This produces the correct result regardless of node insertion order.
For the reproduction case above:
- Current algorithm:
initialRoots = [A, B, A1, A2, A3, B1, B2](7 roots, wrong) - Proposed fix:
rootNodes = [A1, A2, A3, B1, B2](5 roots, correct)
Environment
- beautiful-mermaid 1.1.3
- Node.js v25.8.1 / Linux
Source: lukilabs/beautiful-mermaid