@@ -24,17 +24,14 @@ class MermaidRendererConfig:
2424class MermaidRenderer :
2525 """Renders a DiagramGraph into a Mermaid stateDiagram-v2 source string.
2626
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.
27+ Mermaid's stateDiagram-v2 has a rendering bug
28+ (`mermaid-js/mermaid#4052 <https://github.com/mermaid-js/mermaid/issues/4052>`_)
29+ where transitions whose source or target is a compound state
30+ (``state X { ... }``) **inside a parallel region** crash with
31+ ``Cannot set properties of undefined (setting 'rank')``. To work around
32+ this, the renderer rewrites compound-state endpoints that are descendants
33+ of a parallel state, redirecting them to the compound's initial child.
34+ Compound states outside parallel regions are left unchanged.
3835 """
3936
4037 def __init__ (self , config : Optional [MermaidRendererConfig ] = None ):
@@ -43,13 +40,17 @@ def __init__(self, config: Optional[MermaidRendererConfig] = None):
4340 self ._rendered_transitions : Set [tuple ] = set ()
4441 self ._compound_ids : Set [str ] = set ()
4542 self ._initial_child_map : Dict [str , str ] = {}
43+ self ._parallel_descendant_ids : Set [str ] = set ()
44+ self ._all_descendants_map : Dict [str , Set [str ]] = {}
4645
4746 def render (self , graph : DiagramGraph ) -> str :
4847 """Render a DiagramGraph to a Mermaid stateDiagram-v2 string."""
4948 self ._active_ids = []
5049 self ._rendered_transitions = set ()
5150 self ._compound_ids = graph .compound_state_ids
5251 self ._initial_child_map = self ._build_initial_child_map (graph .states )
52+ self ._parallel_descendant_ids = self ._collect_parallel_descendants (graph .states )
53+ self ._all_descendants_map = self ._build_all_descendants_map (graph .states )
5354
5455 lines : List [str ] = []
5556 lines .append ("stateDiagram-v2" )
@@ -80,9 +81,52 @@ def _build_initial_child_map(self, states: List[DiagramState]) -> Dict[str, str]
8081 result .update (self ._build_initial_child_map (state .children ))
8182 return result
8283
84+ @staticmethod
85+ def _collect_parallel_descendants (
86+ states : List [DiagramState ],
87+ inside_parallel : bool = False ,
88+ ) -> Set [str ]:
89+ """Collect IDs of all states that are descendants of a parallel state."""
90+ result : Set [str ] = set ()
91+ for state in states :
92+ if inside_parallel :
93+ result .add (state .id )
94+ child_inside = inside_parallel or state .type == StateType .PARALLEL
95+ result .update (
96+ MermaidRenderer ._collect_parallel_descendants (state .children , child_inside )
97+ )
98+ return result
99+
100+ def _build_all_descendants_map (self , states : List [DiagramState ]) -> Dict [str , Set [str ]]:
101+ """Map each compound state ID to the set of all its descendant IDs."""
102+ result : Dict [str , Set [str ]] = {}
103+ for state in states :
104+ if state .children :
105+ result [state .id ] = self ._collect_recursive_descendants (state .children )
106+ result .update (self ._build_all_descendants_map (state .children ))
107+ return result
108+
109+ @staticmethod
110+ def _collect_recursive_descendants (states : List [DiagramState ]) -> Set [str ]:
111+ """Collect all state IDs in a subtree recursively."""
112+ ids : Set [str ] = set ()
113+ for s in states :
114+ ids .add (s .id )
115+ ids .update (MermaidRenderer ._collect_recursive_descendants (s .children ))
116+ return ids
117+
83118 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 :
119+ """Resolve a transition endpoint for Mermaid compatibility.
120+
121+ Only redirects compound states that are inside a parallel region —
122+ this is where Mermaid's rendering bug (mermaid-js/mermaid#4052) occurs.
123+ Compound states outside parallel regions are left unchanged.
124+ """
125+ if (
126+ state_id in self ._compound_ids
127+ and state_id in self ._parallel_descendant_ids
128+ and state_id in self ._initial_child_map
129+ ):
86130 return self ._initial_child_map [state_id ]
87131 return state_id
88132
@@ -197,25 +241,44 @@ def _render_scope_transitions(
197241 lines : List [str ],
198242 indent : int ,
199243 ) -> None :
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.
244+ """Render transitions that belong to this scope level.
245+
246+ A transition belongs to scope S if all its endpoints are *reachable*
247+ from S (either directly in S or descendants of a compound in S) **and**
248+ the transition is not fully internal to a single compound in S (those
249+ are rendered by the compound's inner scope).
250+
251+ This allows cross-boundary transitions (e.g., an outer state targeting
252+ a history pseudo-state inside a compound) to be rendered at the correct
253+ scope level — Mermaid draws the arrow crossing the compound border.
254+
255+ Mermaid crashes when the source or target is a compound state inside a
256+ parallel region (mermaid-js/mermaid#4052). For those cases, endpoints
257+ are redirected to the compound's initial child via ``_resolve_endpoint``.
208258 """
259+ # Build the descendant sets for compounds in this scope
260+ compound_descendants : Dict [str , Set [str ]] = {}
261+ expanded : Set [str ] = set (scope_ids )
262+ for sid in scope_ids :
263+ if sid in self ._all_descendants_map :
264+ compound_descendants [sid ] = self ._all_descendants_map [sid ]
265+ expanded |= self ._all_descendants_map [sid ]
266+
209267 for t in transitions :
210268 if t .is_initial or t .is_internal :
211269 continue
212270
213271 targets = t .targets if t .targets else [t .source ]
214272
215- # Check scope membership with original IDs
216- if t .source not in scope_ids :
273+ # All endpoints must be reachable from this scope
274+ if t .source not in expanded :
217275 continue
218- if not all (target in scope_ids for target in targets ):
276+ if not all (target in expanded for target in targets ):
277+ continue
278+
279+ # Skip transitions fully internal to a single compound —
280+ # those will be rendered by the compound's inner scope.
281+ if self ._is_fully_internal (t .source , targets , compound_descendants ):
219282 continue
220283
221284 # Resolve endpoints for rendering (redirect compound → initial child)
@@ -229,6 +292,18 @@ def _render_scope_transitions(
229292 self ._rendered_transitions .add (key )
230293 self ._render_single_transition (t , source , target , lines , indent )
231294
295+ @staticmethod
296+ def _is_fully_internal (
297+ source : str ,
298+ targets : List [str ],
299+ compound_descendants : Dict [str , Set [str ]],
300+ ) -> bool :
301+ """Check if all endpoints belong to the same compound's descendants."""
302+ for descendants in compound_descendants .values ():
303+ if source in descendants and all (tgt in descendants for tgt in targets ):
304+ return True
305+ return False
306+
232307 def _render_single_transition (
233308 self ,
234309 transition : DiagramTransition ,
0 commit comments