A* pathfinding in edge-bundling exhausts memory (Map maximum size exceeded) on dense TD graphs

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

Bug

renderMermaidASCII throws RangeError: Map maximum size exceeded on dense top-down flowcharts with multiple fan-in edge bundles.

Reproduction

typescript
import { renderMermaidASCII } from 'beautiful-mermaid';

const code = `graph TD
    A["AAA<br>(keita)"] --> C["CCC"]
    B["BBB<br>(yuriko)"] --> C
    C --> D["DDDD"]
    D --> E["EEEE"]

    A1["1 / 2"] --> A
    A2["3 / 4"] --> A
    A3["5 / 6"] --> A
    A4["XXX<br>(YYY ZZZ)"] --> A

    B1["77 77<br>(7 / 7 / 7)"] --> B
    B2["88-88<br>(99 99)"] --> B
    B3["111s 222s"] --> B

    D --> F{"F?"}
    F -->|Yes| G["High level<br>Tr"]
    F -->|No| H["Dumb Tr<br>S"]`;

renderMermaidASCII(code); // => RangeError: Map maximum size exceeded

Stack trace

RangeError: Map maximum size exceeded
    at Map.set (<anonymous>)
    at getPath (dist/index.js)           // src/ascii/pathfinder.ts:164
    at routeBundledEdges (dist/index.js)  // src/ascii/edge-bundling.ts:277
    at processBundles (dist/index.js)     // src/ascii/edge-bundling.ts:326
    at createMapping (dist/index.js)      // src/ascii/grid.ts:537

Analysis

The diagram has 13 nodes and 15 edges forming three edge bundles:

  • Fan-in to A: A1, A2, A3, A4 (4 unlabelled edges)
  • Fan-in to B: B1, B2, B3 (3 unlabelled edges)
  • Fan-out from D: D -> E, D -> F (2 unlabelled edges)

routeBundledEdges computes a junction point via calculateJunctionPoint and then calls getPath to route from each source's exit cell to the junction. On this graph, the junction point or its neighbors can become enclosed by other nodes' 3x3 grid blocks, making it unreachable through free cells.

The root cause is in getPath (src/ascii/pathfinder.ts:121-173):

  1. No upper-bound check on grid coordinates. isFreeInGrid (line 112) rejects negative coordinates but imposes no positive limit. The search space is unbounded in the +x/+y direction.
  2. No iteration limit. When the destination is unreachable, A* expands every reachable free cell in the semi-infinite positive quadrant until V8's Map size limit (~16.7M entries) is hit.

Suggested fix

Add a maximum iteration guard to getPath. When exceeded, return null (no path found) — the caller already handles this gracefully by falling back to a straight-line segment:

typescript
// src/ascii/pathfinder.ts — getPath()
const MAX_ITERATIONS = 50_000

export function getPath(
  grid: Map<string, AsciiNode>,
  from: GridCoord,
  to: GridCoord,
): GridCoord[] | null {
  const pq = new MinHeap()
  pq.push({ coord: from, priority: 0 })

  const costSoFar = new Map<string, number>()
  costSoFar.set(gridKey(from), 0)

  const cameFrom = new Map<string, GridCoord | null>()
  cameFrom.set(gridKey(from), null)

  let iterations = 0                        // +
  while (pq.length > 0) {
    if (++iterations > MAX_ITERATIONS) {     // +
      return null                            // +
    }                                        // +

    // ... rest unchanged
  }

  return null
}

An alternative / complementary approach would be to clamp the search area to a bounding box derived from the grid's occupied extent (e.g., max occupied coordinate + margin).

Environment

  • beautiful-mermaid 1.1.3
  • Node.js v25.8.1 / Linux

Source: lukilabs/beautiful-mermaid