Skip to content

Commit 9dca47b

Browse files
committed
fix: render cross-boundary transitions and restrict Mermaid compound workaround to parallel regions
The Mermaid renderer had two issues: 1. Cross-scope transitions (e.g., an outer state targeting a history pseudo-state inside a compound) were silently dropped because `_render_scope_transitions` only rendered transitions where both endpoints were direct members of the same scope. Now the scope check expands to include descendants of compound states, while skipping transitions fully internal to a single compound (handled by the inner scope). 2. The compound→initial-child redirect (workaround for mermaid-js/mermaid#4052) was applied universally, but the bug only affects compound states inside parallel regions. Now the redirect is restricted to parallel descendants, leaving compound states outside parallel regions unchanged. Adds a ParallelCompoundSC showcase that exercises the Mermaid bug pattern (transition targeting a compound inside a parallel region), with Graphviz vs Mermaid comparison in the visual showcase docs.
1 parent 462a0f1 commit 9dca47b

File tree

4 files changed

+314
-44
lines changed

4 files changed

+314
-44
lines changed

docs/diagram.md

Lines changed: 92 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -545,9 +545,9 @@ dot.write_png("order_control_class.png")
545545
## Visual showcase
546546

547547
This section shows how each state machine feature is rendered in diagrams.
548-
Each example includes the class definition, the **class** diagram (no
549-
active state), and **instance** diagrams (with the current state
550-
highlighted after sending events).
548+
Each example includes the class definition, diagrams in both **Graphviz**
549+
and **Mermaid** formats, and **instance** diagrams with the current state
550+
highlighted after sending events.
551551

552552

553553
### Simple states
@@ -560,7 +560,12 @@ A minimal state machine with three atomic states and linear transitions.
560560
```
561561

562562
```{statemachine-diagram} tests.machines.showcase_simple.SimpleSC
563-
:caption: Class
563+
:caption: Class (Graphviz)
564+
```
565+
566+
```{statemachine-diagram} tests.machines.showcase_simple.SimpleSC
567+
:format: mermaid
568+
:caption: Class (Mermaid)
564569
```
565570

566571
```{statemachine-diagram} tests.machines.showcase_simple.SimpleSC
@@ -589,7 +594,12 @@ States can declare `entry` / `exit` callbacks, shown in the state label.
589594
```
590595

591596
```{statemachine-diagram} tests.machines.showcase_actions.ActionsSC
592-
:caption: Class
597+
:caption: Class (Graphviz)
598+
```
599+
600+
```{statemachine-diagram} tests.machines.showcase_actions.ActionsSC
601+
:format: mermaid
602+
:caption: Class (Mermaid)
593603
```
594604

595605
```{statemachine-diagram} tests.machines.showcase_actions.ActionsSC
@@ -608,7 +618,12 @@ Transitions can have `cond` guards, shown in brackets on the edge label.
608618
```
609619

610620
```{statemachine-diagram} tests.machines.showcase_guards.GuardSC
611-
:caption: Class
621+
:caption: Class (Graphviz)
622+
```
623+
624+
```{statemachine-diagram} tests.machines.showcase_guards.GuardSC
625+
:format: mermaid
626+
:caption: Class (Mermaid)
612627
```
613628

614629
```{statemachine-diagram} tests.machines.showcase_guards.GuardSC
@@ -627,7 +642,12 @@ A transition from a state back to itself.
627642
```
628643

629644
```{statemachine-diagram} tests.machines.showcase_self_transition.SelfTransitionSC
630-
:caption: Class
645+
:caption: Class (Graphviz)
646+
```
647+
648+
```{statemachine-diagram} tests.machines.showcase_self_transition.SelfTransitionSC
649+
:format: mermaid
650+
:caption: Class (Mermaid)
631651
```
632652

633653
```{statemachine-diagram} tests.machines.showcase_self_transition.SelfTransitionSC
@@ -646,7 +666,12 @@ Internal transitions execute actions without exiting/entering the state.
646666
```
647667

648668
```{statemachine-diagram} tests.machines.showcase_internal.InternalSC
649-
:caption: Class
669+
:caption: Class (Graphviz)
670+
```
671+
672+
```{statemachine-diagram} tests.machines.showcase_internal.InternalSC
673+
:format: mermaid
674+
:caption: Class (Mermaid)
650675
```
651676

652677
```{statemachine-diagram} tests.machines.showcase_internal.InternalSC
@@ -666,10 +691,15 @@ its initial child.
666691
```
667692

668693
```{statemachine-diagram} tests.machines.showcase_compound.CompoundSC
669-
:caption: Class
694+
:caption: Class (Graphviz)
670695
:target:
671696
```
672697

698+
```{statemachine-diagram} tests.machines.showcase_compound.CompoundSC
699+
:format: mermaid
700+
:caption: Class (Mermaid)
701+
```
702+
673703
```{statemachine-diagram} tests.machines.showcase_compound.CompoundSC
674704
:events:
675705
:caption: Off
@@ -699,10 +729,15 @@ A parallel state activates all its regions simultaneously.
699729
```
700730

701731
```{statemachine-diagram} tests.machines.showcase_parallel.ParallelSC
702-
:caption: Class
732+
:caption: Class (Graphviz)
703733
:target:
704734
```
705735

736+
```{statemachine-diagram} tests.machines.showcase_parallel.ParallelSC
737+
:format: mermaid
738+
:caption: Class (Mermaid)
739+
```
740+
706741
```{statemachine-diagram} tests.machines.showcase_parallel.ParallelSC
707742
:events: enter
708743
:caption: Both active
@@ -716,6 +751,41 @@ A parallel state activates all its regions simultaneously.
716751
```
717752

718753

754+
### Parallel with cross-boundary transitions
755+
756+
A transition targeting a compound state **inside** a parallel region triggers a
757+
rendering bug in Mermaid (`mermaid-js/mermaid#4052`). The Mermaid renderer works
758+
around this by redirecting the arrow to the compound's initial child — compare the
759+
``rebuild`` arrow in both diagrams below.
760+
761+
```{literalinclude} ../tests/machines/showcase_parallel_compound.py
762+
:pyobject: ParallelCompoundSC
763+
:language: python
764+
```
765+
766+
```{statemachine-diagram} tests.machines.showcase_parallel_compound.ParallelCompoundSC
767+
:caption: Class (Graphviz) — ``rebuild`` points to the Build compound border
768+
:target:
769+
```
770+
771+
```{statemachine-diagram} tests.machines.showcase_parallel_compound.ParallelCompoundSC
772+
:format: mermaid
773+
:caption: Class (Mermaid) — ``rebuild`` is redirected to Compile (initial child of Build)
774+
```
775+
776+
```{statemachine-diagram} tests.machines.showcase_parallel_compound.ParallelCompoundSC
777+
:events: start, do_build
778+
:caption: Build done
779+
:target:
780+
```
781+
782+
```{statemachine-diagram} tests.machines.showcase_parallel_compound.ParallelCompoundSC
783+
:events: start, do_build, do_test
784+
:caption: Pipeline done → Review
785+
:target:
786+
```
787+
788+
719789
### History states (shallow)
720790

721791
A history pseudo-state remembers the last active child of a compound state.
@@ -726,10 +796,15 @@ A history pseudo-state remembers the last active child of a compound state.
726796
```
727797

728798
```{statemachine-diagram} tests.machines.showcase_history.HistorySC
729-
:caption: Class
799+
:caption: Class (Graphviz)
730800
:target:
731801
```
732802

803+
```{statemachine-diagram} tests.machines.showcase_history.HistorySC
804+
:format: mermaid
805+
:caption: Class (Mermaid)
806+
```
807+
733808
```{statemachine-diagram} tests.machines.showcase_history.HistorySC
734809
:events: begin, advance
735810
:caption: Step2
@@ -759,10 +834,15 @@ Deep history remembers the exact leaf state across nested compounds.
759834
```
760835

761836
```{statemachine-diagram} tests.machines.showcase_deep_history.DeepHistorySC
762-
:caption: Class
837+
:caption: Class (Graphviz)
763838
:target:
764839
```
765840

841+
```{statemachine-diagram} tests.machines.showcase_deep_history.DeepHistorySC
842+
:format: mermaid
843+
:caption: Class (Mermaid)
844+
```
845+
766846
```{statemachine-diagram} tests.machines.showcase_deep_history.DeepHistorySC
767847
:events: dive, enter_inner, go
768848
:caption: Inner/B

statemachine/contrib/diagram/renderers/mermaid.py

Lines changed: 99 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -24,17 +24,14 @@ class MermaidRendererConfig:
2424
class 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

Comments
 (0)