Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 3 additions & 1 deletion AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -86,7 +86,9 @@ Working:

Known gaps:

- Full SCXML conformance still has known `more-parallel` failures.
- The configured 54-case SCXML suite passes, including the enabled
`more-parallel` cases, but broader W3C datamodel and executable-content
conformance is not claimed.
- Graphviz export and graph/test helper APIs beyond Mermaid are future work.
- SCXML condition support is intentionally not a general JavaScript evaluator.

Expand Down
6 changes: 6 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,12 @@ All notable changes to this project will be documented here.
- Updated PyPI-facing installation docs to use `pip install xstate` for the
released package.

### Fixed

- Corrected SCXML transition-domain resolution so external transitions inside
parallel states re-enter every affected region and resolve conflicts in
document order.

## 0.6.0 - 2026-06-28

### Added
Expand Down
5 changes: 2 additions & 3 deletions CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -48,9 +48,8 @@ git submodule update --init
poetry run python -m pytest tests/test_scxml.py
```

The current known full-SCXML gap is the `more-parallel` conformance group.
Do not treat those existing failures as caused by unrelated docs or tooling
changes.
The configured SCXML suite must pass in full. It is a focused subset and does
not yet claim broad W3C datamodel or executable-content conformance.

## Pull Requests

Expand Down
9 changes: 5 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -427,9 +427,10 @@ git submodule update --init
poetry run python -m pytest tests/test_scxml.py
```

Current branch result: `45 passed`, `8 failed`; the remaining failures are the
known `more-parallel` conformance cases. The `cond-js` subset passes with the
safe Boolean evaluator.
Current configured-suite result: `54 passed`, `0 failed`, including all enabled
`more-parallel` cases. This is a focused SCXML subset rather than a claim of full
W3C conformance; broader datamodel and executable-content coverage remains
future work. The `cond-js` subset passes with the safe Boolean evaluator.

## Developing

Expand Down Expand Up @@ -470,7 +471,7 @@ release target needs to be checked against different refs.
| Snapshot queries | `tags`, `meta`, `has_tag`/`hasTag`, and `state_in`/`stateIn` are present |
| Diagrams | Dependency-free Mermaid export is present |
| Async | `AsyncInterpreter`, async actors, `from_observable`, and `to_promise` are present |
| SCXML | XML import works; safe Boolean cond subset works; `more-parallel` conformance remains open |
| SCXML | Configured 54-case suite passes; broader W3C datamodel and executable-content coverage remains open |
| Persistence | Snapshot serialization and restore helpers are present |

## Related Projects
Expand Down
9 changes: 5 additions & 4 deletions docs/PRODUCT.md
Original file line number Diff line number Diff line change
Expand Up @@ -36,7 +36,7 @@ bridge between XState's JSON ecosystem and Python backends.
| Actor model | `create_actor`, `ActorSystem`, spawn, parent/child tree, `send_parent`, `send_to` |
| Actor logic | `from_promise`, `from_callback`, `from_observable`, `to_promise` |
| Invoke | Child actor lifetime reconciliation with `done.invoke.*` and `error.platform.*` events |
| SCXML XML import | Present; safe Boolean cond subset; known `more-parallel` failures remain |
| SCXML XML import | Present; safe Boolean cond subset; configured 54-case suite passes |

## What Works Today

Expand All @@ -57,7 +57,8 @@ bridge between XState's JSON ecosystem and Python backends.
`MachineSnapshot`, `create_actor`, and `setup`.
- Primary suite passes in current Python 3.13/3.14 CI.
- SCXML `cond-js` subset result: `4 passed`.
- Full SCXML result: `45 passed`, `8 failed` in known `more-parallel` cases.
- Configured SCXML result: `54 passed`, `0 failed`, including all enabled
`more-parallel` cases.

## Competitive Position

Expand All @@ -77,7 +78,7 @@ JavaScript and wanting the same machine shape in Python services.
|---|---|
| High | Publish 0.7.0 to PyPI as `xstate` |
| High | Document the public API by concept: machines, guards/actions, context, interpreter, actors, async, SCXML |
| High | Fix the remaining SCXML `more-parallel` conformance cases |
| High | Expand SCXML coverage beyond the configured subset without adding JavaScript evaluation |
| Medium | Document snapshot persistence and restore helpers |
| Medium | Inspector protocol compatibility |
| Medium | More XState v5 utilities: `provide`, action helpers, graph/test helpers |
Expand All @@ -89,7 +90,7 @@ JavaScript and wanting the same machine shape in Python services.
|---|---|
| PyPI release | `pip install xstate` works after the 0.7.0 GitHub Release |
| Primary test count | Maintain 300+ focused tests |
| SCXML pass rate | Resolve known `more-parallel` group |
| SCXML pass rate | Keep the configured suite green while expanding supported coverage |
| Docs | README plus concept docs for the main public APIs |
| Examples | JSON, sync, async, actor, and integration examples |

Expand Down
4 changes: 2 additions & 2 deletions docs/comparison.md
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ release-readiness work.
| XState JSON | No | No | **Yes, native** | Yes | No | No |
| SCXML algorithm | No | Yes | **Yes, partial conformance** | Partial | Yes | No |
| Hierarchical states | Yes, extension | Yes | Yes | Yes | Yes | No |
| Parallel states | Yes, extension | Yes | Yes, with known SCXML `more-parallel` gaps | Yes | Yes | No |
| Parallel states | Yes, extension | Yes | Yes; configured `more-parallel` cases pass | Yes | Yes | No |
| Guards/actions | Yes | Yes | Yes, named or inline | Yes | Yes | Limited |
| Context/extended state | No native XState-style context | Yes | Yes, with `assign` and snapshot isolation | Yes | Yes | No |
| Eventless transitions | No | Yes | Yes, `always` and legacy empty-event form | Yes | Yes | No |
Expand Down Expand Up @@ -72,7 +72,7 @@ structure with a JavaScript/XState frontend or a Stately-authored design.
| Gap | Notes |
|---|---|
| PyPI release | 0.7.0 packaging metadata and release workflow are ready; publish via GitHub Release. |
| SCXML conformance | Current full SCXML run is `45 passed`, `8 failed`; remaining failures are the known `more-parallel` cases. |
| SCXML conformance | Configured SCXML suite is `54 passed`, `0 failed`; broader W3C coverage is not yet claimed. |
| Full ECMAScript cond support | Intentionally not implemented; unsupported SCXML expressions raise `InvalidConfigError`. |
| Graph/test utilities | Mermaid export exists; no graph traversal/test-path helpers yet. |
| Inspector protocol | No `@statelyai/inspect` compatibility yet. |
Expand Down
5 changes: 4 additions & 1 deletion src/xstate/algorithm.py
Original file line number Diff line number Diff line change
Expand Up @@ -225,8 +225,11 @@ def get_transition_domain(


def find_lcca(state_list: list[StateNode]) -> StateNode | None:
"""Return the least common compound ancestor for SCXML transition domains."""
for anc in get_proper_ancestors(state_list[0], state2=None):
if all(is_descendent(s, state2=anc) for s in state_list[1:]):
if is_compound_state(anc) and all(

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Preserve root-parallel transition domains

When the machine root is itself type: "parallel", this filter skips that root parallel ancestor, so find_lcca returns None for an external transition declared on a region that targets a descendant such as a.on.RESET -> #a1. The entry-set code then uses ancestor=None and re-enters the machine root, causing root entry actions to run again on every regional reset; through the public Machine.transition path there is no matching root exit because configurations are reconstructed from state.value without the root node. Before this change the root parallel node was the transition domain, so this root-level side effect did not occur.

Useful? React with 👍 / 👎.

is_descendent(s, state2=anc) for s in state_list[1:]
):
return anc
return None

Expand Down
154 changes: 153 additions & 1 deletion tests/test_parallel.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
"""Tests for parallel (orthogonal) states (0.2.0)."""

from xstate import Machine
import pytest

from xstate import Machine, interpret


def make_word():
Expand Down Expand Up @@ -131,3 +133,153 @@ def test_parallel_on_done_when_all_regions_final():
# Both regions final -> done.state.running -> onDone.
state = machine.transition(state, "FINISH_B")
assert state.value == "done"


def test_external_region_transition_reenters_parallel_siblings():
calls: list[str] = []
machine = Machine(
{
"id": "workflow",
"initial": "running",
"states": {
"running": {
"type": "parallel",
"states": {
"a": {
"initial": "a1",
"on": {"RESET_A": "#a1"},
"states": {
"a1": {"id": "a1"},
"a2": {},
},
},
"b": {
"entry": "enterB",
"exit": "exitB",
"initial": "b1",
"states": {
"b1": {"on": {"ADVANCE_B": "b2"}},
"b2": {},
},
},
},
}
},
},
actions={
"enterB": lambda: calls.append("enterB"),
"exitB": lambda: calls.append("exitB"),
},
)
service = interpret(machine).start()
calls.clear()

service.send("ADVANCE_B")
assert service.state.value == {"running": {"a": "a1", "b": "b2"}}

service.send("RESET_A")

assert service.state.value == {"running": {"a": "a1", "b": "b1"}}
assert calls == ["exitB", "enterB"]
service.stop()


def _parallel_conflict_machine(first_region: str) -> Machine:
a = {
"initial": "a1",
"on": {"T": "#a2"},
"states": {
"a1": {},
"a2": {"id": "a2"},
},
}
b = {
"initial": "b1",
"states": {
"b1": {"on": {"T": "b2"}},
"b2": {},
},
}
regions = {"a": a, "b": b} if first_region == "a" else {"b": b, "a": a}
return Machine(
{
"id": "conflict",
"initial": "running",
"states": {
"running": {
"type": "parallel",
"states": regions,
}
},
}
)


@pytest.mark.parametrize(
("first_region", "expected"),
[
("a", {"running": {"a": "a2", "b": "b1"}}),
("b", {"running": {"a": "a1", "b": "b2"}}),
],
)
def test_parallel_conflicts_follow_document_order(first_region, expected):
machine = _parallel_conflict_machine(first_region)

state = machine.transition(machine.initial_state, "T")

assert state.value == expected


def test_parallel_conflict_with_nested_target_reenters_sibling_initial_state():
machine = Machine(
{
"id": "nested-conflict",
"initial": "running",
"states": {
"running": {
"type": "parallel",
"states": {
"a": {
"initial": "a1",
"on": {"T": "#a22"},
"states": {
"a1": {
"initial": "a11",
"states": {"a11": {}, "a12": {}},
},
"a2": {
"initial": "a21",
"states": {
"a21": {},
"a22": {"id": "a22"},
},
},
},
},
"b": {
"initial": "b1",
"states": {
"b1": {
"initial": "b11",
"states": {
"b11": {"on": {"T": "b12"}},
"b12": {},
},
},
"b2": {},
},
},
},
}
},
}
)

state = machine.transition(machine.initial_state, "T")

assert state.value == {
"running": {
"a": {"a2": "a22"},
"b": {"b1": "b11"},
}
}
1 change: 1 addition & 0 deletions tests/test_scxml.py
Original file line number Diff line number Diff line change
Expand Up @@ -73,6 +73,7 @@
"hierarchy+documentOrder",
"parallel",
"parallel+interrupt",
"more-parallel",
}

test_files = [
Expand Down
Loading