Skip to content

Commit 462a0f1

Browse files
committed
fix: work around Mermaid crash for transitions inside parallel regions
Mermaid's stateDiagram-v2 crashes when a transition targets or originates from a compound state inside a parallel region (mermaid-js/mermaid#4052). The MermaidRenderer now redirects such endpoints to the compound's initial child state. Also filter dot-form event aliases (e.g. done.invoke.X) from diagram output — the fix lives in the extractor so all renderers benefit. Closes #594
1 parent 8729090 commit 462a0f1

File tree

5 files changed

+203
-13
lines changed

5 files changed

+203
-13
lines changed

docs/diagram.md

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -95,12 +95,22 @@ the command line.
9595

9696
| Format | Aliases | Description | Dependencies |
9797
|--------|---------|-------------|--------------|
98-
| `mermaid` | | [Mermaid stateDiagram-v2](https://mermaid.js.org/syntax/stateDiagram.html) source | None |
98+
| `mermaid` | | [Mermaid stateDiagram-v2](https://mermaid.js.org/syntax/stateDiagram.html) source | None [^mermaid] |
9999
| `md` | `markdown` | Transition table (pipe-delimited Markdown) | None |
100100
| `rst` | | Transition table (RST grid table) | None |
101101
| `dot` | | [Graphviz DOT](https://graphviz.org/doc/info/lang.html) language source | pydot |
102102
| `svg` | | SVG markup (generated via DOT) | pydot, Graphviz |
103103

104+
[^mermaid]: Mermaid has a known rendering bug
105+
([mermaid-js/mermaid#4052](https://github.com/mermaid-js/mermaid/issues/4052))
106+
where transitions targeting or originating from a compound state inside a
107+
parallel region crash the renderer. As a workaround, the `MermaidRenderer`
108+
redirects such transitions to the compound's initial child state. The
109+
visual result is equivalent — Mermaid draws the arrow crossing into the
110+
compound boundary — but the arrow points to the child rather than the
111+
compound border. This workaround will be revisited when the upstream bug
112+
is resolved.
113+
104114

105115
### Using `format()`
106116

statemachine/contrib/diagram/extract.py

Lines changed: 29 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@
1313
if TYPE_CHECKING:
1414
from statemachine.state import State
1515
from statemachine.statemachine import StateChart
16+
from statemachine.transition import Transition
1617

1718
# A StateChart class or instance — both expose the same structural metadata.
1819
MachineRef = Union["StateChart", "type[StateChart]"]
@@ -101,6 +102,33 @@ def _extract_state(
101102
)
102103

103104

105+
def _format_event_names(transition: "Transition") -> str:
106+
"""Build a display string for the events that trigger a transition.
107+
108+
``_expand_event_id`` registers both the Python attribute name
109+
(``done_invoke_X``) and the SCXML dot form (``done.invoke.X``) under the
110+
same transition. For diagram display we only want unique *semantic* events,
111+
keeping the Python attribute name when an alias pair exists.
112+
"""
113+
events = list(transition.events)
114+
if not events:
115+
return ""
116+
117+
all_ids = {str(e) for e in events}
118+
119+
display: List[str] = []
120+
for event in events:
121+
eid = str(event)
122+
# Skip dot-form aliases (e.g. "done.invoke.X") when the underscore
123+
# form ("done_invoke_X") is also registered on this transition.
124+
if "." in eid and eid.replace(".", "_") in all_ids:
125+
continue
126+
if eid not in display:
127+
display.append(eid)
128+
129+
return " ".join(display)
130+
131+
104132
def _extract_transitions_from_state(state: "State") -> List[DiagramTransition]:
105133
"""Extract transitions from a single state (non-recursive)."""
106134
result: List[DiagramTransition] = []
@@ -114,7 +142,7 @@ def _extract_transitions_from_state(state: "State") -> List[DiagramTransition]:
114142
DiagramTransition(
115143
source=transition.source.id,
116144
targets=target_ids,
117-
event=transition.event,
145+
event=_format_event_names(transition),
118146
guards=cond_strs,
119147
is_internal=transition.internal,
120148
)

statemachine/contrib/diagram/renderers/mermaid.py

Lines changed: 57 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
from dataclasses import dataclass
2+
from typing import Dict
23
from typing import List
34
from typing import Optional
45
from typing import Set
@@ -21,17 +22,34 @@ class MermaidRendererConfig:
2122

2223

2324
class 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:

tests/test_contrib_diagram.py

Lines changed: 103 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@
88
from statemachine.contrib.diagram import DotGraphMachine
99
from statemachine.contrib.diagram import main
1010
from statemachine.contrib.diagram import quickchart_write_svg
11+
from statemachine.contrib.diagram.extract import _format_event_names
1112
from statemachine.contrib.diagram.model import ActionType
1213
from statemachine.contrib.diagram.model import StateType
1314
from statemachine.contrib.diagram.renderers.dot import DotRenderer
@@ -698,6 +699,108 @@ def test_resolve_initial_fallback(self):
698699
assert states[0].is_initial is True
699700

700701

702+
class TestFormatEventNames:
703+
"""Tests for _format_event_names — alias filtering for diagram display."""
704+
705+
def test_simple_event_unchanged(self):
706+
"""A plain event with no aliases is returned as-is."""
707+
708+
class SM(StateChart):
709+
s1 = State(initial=True)
710+
s2 = State(final=True)
711+
go = s1.to(s2)
712+
713+
t = SM.s1.transitions[0]
714+
assert _format_event_names(t) == "go"
715+
716+
def test_done_state_alias_filtered(self):
717+
"""done_state_X registers both underscore and dot forms; only underscore is shown."""
718+
719+
class SM(StateChart):
720+
class parent(State.Compound):
721+
child = State(initial=True)
722+
done = State(final=True)
723+
finish = child.to(done)
724+
725+
end = State(final=True)
726+
done_state_parent = parent.to(end)
727+
728+
t = next(t for t in SM.parent.transitions if t.event and "done_state" in t.event)
729+
result = _format_event_names(t)
730+
assert result == "done_state_parent"
731+
assert "done.state" not in result
732+
733+
def test_done_invoke_alias_filtered(self):
734+
"""done_invoke_X alias filtering works the same as done_state_X."""
735+
736+
class SM(StateChart):
737+
s1 = State(initial=True)
738+
s2 = State(final=True)
739+
done_invoke_child = s1.to(s2)
740+
741+
t = SM.s1.transitions[0]
742+
result = _format_event_names(t)
743+
assert result == "done_invoke_child"
744+
assert "done.invoke" not in result
745+
746+
def test_error_alias_filtered(self):
747+
"""error_X registers both error_X and error.X; only underscore is shown."""
748+
749+
class SM(StateChart):
750+
s1 = State(initial=True)
751+
s2 = State(final=True)
752+
error_execution = s1.to(s2)
753+
754+
t = SM.s1.transitions[0]
755+
result = _format_event_names(t)
756+
assert result == "error_execution"
757+
assert "error.execution" not in result
758+
759+
def test_multiple_distinct_events_preserved(self):
760+
"""Multiple distinct events on one transition are all preserved."""
761+
762+
class SM(StateChart):
763+
s1 = State(initial=True)
764+
s2 = State(final=True)
765+
go = s1.to(s2)
766+
also = s1.to(s2)
767+
768+
# Add a second event to the first transition
769+
t = SM.s1.transitions[0]
770+
t.add_event("also")
771+
result = _format_event_names(t)
772+
assert "go" in result
773+
assert "also" in result
774+
775+
def test_eventless_transition_returns_empty(self):
776+
"""A transition with no events returns an empty string."""
777+
778+
class SM(StateChart):
779+
s1 = State(initial=True)
780+
s2 = State(final=True)
781+
s1.to(s2, cond="always_true")
782+
783+
def always_true(self):
784+
return True
785+
786+
# Find the eventless transition
787+
t = next(t for t in SM.s1.transitions if not list(t.events))
788+
assert _format_event_names(t) == ""
789+
790+
def test_dot_only_event_preserved(self):
791+
"""An event whose ID contains dots but has no underscore alias is preserved."""
792+
793+
class SM(StateChart):
794+
s1 = State(initial=True)
795+
s2 = State(final=True)
796+
go = s1.to(s2)
797+
798+
from statemachine.transition import Transition
799+
800+
t = Transition(source=SM.s1, target=SM.s2, event="custom.event")
801+
assert _format_event_names(t) == "custom.event"
802+
803+
701804
class TestDotRendererEdgeCases:
702805
"""Tests for dot.py edge cases."""
703806

tests/test_mermaid_renderer.py

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -249,8 +249,9 @@ class parent(State.Compound, name="Parent"):
249249
assert "[*] --> child1" in result
250250
assert "child1 --> child2 : go" in result
251251
assert "child2 --> [*]" in result
252-
assert "start --> parent : enter" in result
253-
assert "parent --> end : finish" in result
252+
# Compound endpoints are redirected to the initial child (Mermaid workaround)
253+
assert "start --> child1 : enter" in result
254+
assert "child1 --> end : finish" in result
254255

255256
def test_compound_no_duplicate_transitions(self):
256257
"""Transitions inside compound states must not also appear at top level."""

0 commit comments

Comments
 (0)