Root detection in grid layout is order-dependent, misclassifying child nodes as roots

Author: ktrysmtCreated Mar 18, 2026Updated Aug 29, 2026

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

typescript
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:

typescript
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 A

When 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:

typescript
// 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