11from dataclasses import dataclass
2+ from typing import Dict
23from typing import List
34from typing import Optional
45from typing import Set
@@ -21,17 +22,34 @@ class MermaidRendererConfig:
2122
2223
2324class MermaidRenderer :
24- """Renders a DiagramGraph into a Mermaid stateDiagram-v2 source string."""
25+ """Renders a DiagramGraph into a Mermaid stateDiagram-v2 source string.
26+
27+ Mermaid's stateDiagram-v2 has a rendering bug where transitions whose source
28+ or target is a compound state (``state X { ... }``) inside a parallel region
29+ crash with ``Cannot set properties of undefined (setting 'rank')``. To work
30+ around this, the renderer rewrites compound-state endpoints to cross the
31+ boundary:
32+
33+ - Transition **to** a compound → redirected to its initial child.
34+ - Transition **from** a compound → redirected from its initial child.
35+
36+ This is applied universally (not only inside parallel regions) for simplicity
37+ and consistency — the visual effect is equivalent.
38+ """
2539
2640 def __init__ (self , config : Optional [MermaidRendererConfig ] = None ):
2741 self .config = config or MermaidRendererConfig ()
2842 self ._active_ids : List [str ] = []
2943 self ._rendered_transitions : Set [tuple ] = set ()
44+ self ._compound_ids : Set [str ] = set ()
45+ self ._initial_child_map : Dict [str , str ] = {}
3046
3147 def render (self , graph : DiagramGraph ) -> str :
3248 """Render a DiagramGraph to a Mermaid stateDiagram-v2 string."""
3349 self ._active_ids = []
3450 self ._rendered_transitions = set ()
51+ self ._compound_ids = graph .compound_state_ids
52+ self ._initial_child_map = self ._build_initial_child_map (graph .states )
3553
3654 lines : List [str ] = []
3755 lines .append ("stateDiagram-v2" )
@@ -51,6 +69,23 @@ def render(self, graph: DiagramGraph) -> str:
5169
5270 return "\n " .join (lines ) + "\n "
5371
72+ def _build_initial_child_map (self , states : List [DiagramState ]) -> Dict [str , str ]:
73+ """Build a map from compound state ID to its initial child ID (recursive)."""
74+ result : Dict [str , str ] = {}
75+ for state in states :
76+ if state .children :
77+ initial = next ((c for c in state .children if c .is_initial ), None )
78+ if initial :
79+ result [state .id ] = initial .id
80+ result .update (self ._build_initial_child_map (state .children ))
81+ return result
82+
83+ def _resolve_endpoint (self , state_id : str ) -> str :
84+ """Resolve a transition endpoint, redirecting compound states to their initial child."""
85+ if state_id in self ._compound_ids and state_id in self ._initial_child_map :
86+ return self ._initial_child_map [state_id ]
87+ return state_id
88+
5489 def _render_states (
5590 self ,
5691 states : List [DiagramState ],
@@ -162,29 +197,42 @@ def _render_scope_transitions(
162197 lines : List [str ],
163198 indent : int ,
164199 ) -> None :
165- """Render transitions where both source and all targets are in scope_ids."""
200+ """Render transitions where both source and all targets are in scope_ids.
201+
202+ Mermaid does not support transitions where the source or target is a
203+ compound state rendered with ``state X { ... }`` inside a parallel region.
204+ To work around this, endpoints that reference compound states are
205+ redirected to the compound's initial child. Scope membership is checked
206+ on the **original** IDs (which belong to this scope level), while the
207+ rendered arrow uses the **resolved** (possibly redirected) IDs.
208+ """
166209 for t in transitions :
167210 if t .is_initial or t .is_internal :
168211 continue
169212
170213 targets = t .targets if t .targets else [t .source ]
171- # Only render if source is in scope
214+
215+ # Check scope membership with original IDs
172216 if t .source not in scope_ids :
173217 continue
174- # Only render if all targets are in scope
175218 if not all (target in scope_ids for target in targets ):
176219 continue
177220
178- for target in targets :
179- key = (t .source , target , t .event )
221+ # Resolve endpoints for rendering (redirect compound → initial child)
222+ source = self ._resolve_endpoint (t .source )
223+ resolved_targets = [self ._resolve_endpoint (tid ) for tid in targets ]
224+
225+ for target in resolved_targets :
226+ key = (source , target , t .event )
180227 if key in self ._rendered_transitions :
181228 continue
182229 self ._rendered_transitions .add (key )
183- self ._render_single_transition (t , target , lines , indent )
230+ self ._render_single_transition (t , source , target , lines , indent )
184231
185232 def _render_single_transition (
186233 self ,
187234 transition : DiagramTransition ,
235+ source : str ,
188236 target : str ,
189237 lines : List [str ],
190238 indent : int ,
@@ -198,9 +246,9 @@ def _render_single_transition(
198246
199247 label = " " .join (label_parts )
200248 if label :
201- lines .append (f"{ pad } { transition . source } --> { target } : { label } " )
249+ lines .append (f"{ pad } { source } --> { target } : { label } " )
202250 else :
203- lines .append (f"{ pad } { transition . source } --> { target } " )
251+ lines .append (f"{ pad } { source } --> { target } " )
204252
205253 @staticmethod
206254 def _format_action (action : DiagramAction ) -> str :
0 commit comments