A* pathfinding in edge-bundling exhausts memory (Map maximum size exceeded) on dense TD graphs
Bug
renderMermaidASCII throws RangeError: Map maximum size exceeded on dense top-down flowcharts with multiple fan-in edge bundles.
Reproduction
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 exceededStack 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:537Analysis
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):
- 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. - No iteration limit. When the destination is unreachable, A* expands every reachable free cell in the semi-infinite positive quadrant until V8's
Mapsize 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:
// 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