feat: stateIn() guard (0.7.0) - #20
Conversation
Expose XState v5's `stateIn` as a first-class composable guard. It passes
when the machine is currently in the given state and reuses the same matcher
as the internal transition `in` guard (algorithm._matches_in_state).
- guards.py: new `_StateInGuard` + `stateIn(spec)`; spec is "#id", a dotted
"parent.child" path, or a {parent: child} dict. Thread the active
`configuration` through `_ComposableGuard._eval`/`_call` so a stateIn nested
inside and_/or_/not_ can see it; a direct call with no configuration is False
- algorithm.condition_match: pass `configuration` into composable guard eval
- __init__.py: export `stateIn`
Also restores the `# type: ignore[index]` on machine.py:145 that commit
e305fdb dropped, which had left master mypy-red.
Tests: tests/test_state_in_guard.py (8 cases) — by id, dotted path, named
registry, and composition with and_/or_/not_. 356 main-suite tests passing,
ruff + mypy clean. SCXML: 53 pre-existing failures, identical on master
(submodule not initialized here); no regression — the change only passes an
extra arg to composable guards and touches no SCXML algorithm function.
There was a problem hiding this comment.
Code Review
This pull request introduces the stateIn guard to check if the machine is currently in a given state configuration, allowing it to compose with other guards like and_, or_, and not_. The review feedback correctly identifies a critical bug where referencing a registered composable guard by string name inside another composable guard fails because the guard is wrapped in a HandlerAdapter, preventing the active configuration from being threaded through. A fix is suggested to unwrap the HandlerAdapter in _eval, along with a corresponding test case.
Important
The consumer version of Gemini Code Assist on GitHub is being sunset. Starting June 18, 2026, new organization installations will be blocked, and all code review activity will officially cease on July 17, 2026.
For more details on the timeline and next steps, please review the Help Documentation.
| if isinstance(guard, _ComposableGuard): | ||
| return guard._call(context, event, registry) | ||
| return guard._call(context, event, registry, configuration) | ||
| from xstate.handlers import invoke_handler | ||
|
|
||
| return bool(invoke_handler(guard, context, event)) |
There was a problem hiding this comment.
There is a bug when a composable guard (like stateIn) is registered in the machine's guards registry and then referenced by its string name inside another composable guard (e.g., and_("switchIsOn")).
Because all registered guards are wrapped in a HandlerAdapter by the machine, isinstance(guard, _ComposableGuard) evaluates to False in _eval. This causes the guard to be invoked via invoke_handler without the active configuration, leading stateIn to always return False.
To fix this, we should unwrap the HandlerAdapter to check if the underlying guard is a _ComposableGuard before calling _call.
from xstate.handlers import HandlerAdapter, invoke_handler
inner = guard.fn if isinstance(guard, HandlerAdapter) else guard
if isinstance(inner, _ComposableGuard):
return inner._call(context, event, registry, configuration)
return bool(invoke_handler(guard, context, event))| assert actor.get_snapshot().value["worker"] == "waiting" | ||
| actor.send("FLIP") | ||
| actor.send("GO") | ||
| assert actor.get_snapshot().value["worker"] == "active" |
There was a problem hiding this comment.
Add a test case to verify that stateIn works correctly when nested inside a named composable guard in the machine's registry. This ensures that the active configuration is correctly threaded through wrapped handlers.
assert actor.get_snapshot().value["worker"] == "active"
def test_stateIn_nested_in_named_composable_guard():
machine = Machine(
{
"id": "m",
"type": "parallel",
"states": {
"switch": {
"initial": "off",
"states": {
"off": {"on": {"FLIP": "on"}},
"on": {"id": "switchOn", "on": {"FLIP": "off"}},
},
},
"worker": {
"initial": "waiting",
"states": {
"waiting": {
"on": {"GO": {"target": "active", "guard": "canGo"}}
},
"active": {},
},
},
},
},
guards={
"switchIsOn": stateIn("#switchOn"),
"canGo": and_("switchIsOn"),
},
)
actor = create_actor(machine).start()
actor.send("GO")
assert actor.get_snapshot().value["worker"] == "waiting"
actor.send("FLIP")
actor.send("GO")
assert actor.get_snapshot().value["worker"] == "active"There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 112d1b1556
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
| if isinstance(guard, _ComposableGuard): | ||
| return guard._call(context, event, registry) | ||
| return guard._call(context, event, registry, configuration) |
There was a problem hiding this comment.
Unwrap named stateIn guards before invoking
When a composable guard references a named stateIn guard, the registry value has already been wrapped in HandlerAdapter, so this isinstance(..., _ComposableGuard) check misses it and the code falls through to invoke_handler. That calls stateIn.__call__ without the current configuration, making and_("isReady"), or_("isReady", ...), or not_("isReady") evaluate as if the machine is never in that state even when guards={"isReady": stateIn("ready")} is registered. This breaks the advertised composition of named stateIn guards; unwrap HandlerAdapter here like condition_match() does before calling _call(..., configuration).
Useful? React with 👍 / 👎.
There was a problem hiding this comment.
Pull request overview
Adds XState v5-style stateIn() as a first-class composable guard so users can gate transitions based on the machine’s current configuration, including when nested inside existing and_/or_/not_ combinators.
Changes:
- Introduces
stateIn(spec)inguards.pyand threadsconfigurationthrough composable guard evaluation so nestedstateInworks correctly. - Updates
algorithm.condition_match()to pass the activeconfigurationinto composable guard execution. - Exports
stateInpublicly and adds a dedicated test suite covering id/path/dict forms and composition.
Reviewed changes
Copilot reviewed 6 out of 6 changed files in this pull request and generated 2 comments.
Show a summary per file
| File | Description |
|---|---|
src/xstate/guards.py |
Adds stateIn guard and updates composable-guard evaluation to accept/pass configuration. |
src/xstate/algorithm.py |
Passes configuration into composable guard evaluation inside condition_match(). |
src/xstate/__init__.py |
Exports stateIn from the public package surface (__all__). |
src/xstate/machine.py |
Restores a # type: ignore[index] to address mypy indexing complaints. |
tests/test_state_in_guard.py |
Adds coverage for stateIn behavior (standalone false, transition use, composition, registry resolution). |
CLAUDE.md |
Documents stateIn as a working 0.7.0 feature. |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| if configuration is None: | ||
| return False | ||
| from xstate.algorithm import _matches_in_state | ||
|
|
||
| return _matches_in_state(self._spec, configuration) |
| *spec* is ``"#id"``, a dotted ``"parent.child"`` path, or a ``{parent: child}`` | ||
| dict — the same shape accepted by a transition ``in`` guard. |
Summary
Second 0.7.0 feature — XState v5's
stateInguard. We already hadin_statematchinginternally on transitions; this exposes it as a first-class composable guard that passes when the
machine is currently in a given state, and composes with
and_/or_/not_.specaccepts the same shapes as a transitioninguard:"#id", a dotted"parent.child"path, or a
{parent: child}dict.What changed
src/xstate/guards.py_StateInGuard+stateIn(spec). Threaded the activeconfigurationthrough_ComposableGuard._eval/_callso astateInnested insideand_/or_/not_can see it. A direct call with no configuration returnsFalse.src/xstate/algorithm.pycondition_match()passesconfigurationinto composable-guard evaluation (one extra arg — no SCXML algorithm function touched)src/xstate/__init__.pystateInsrc/xstate/machine.py# type: ignore[index]on line 145 that commite305fdbdropped (it had left master mypy-red)tests/test_state_in_guard.pyCLAUDE.mdstateInin the "working" feature listDesign notes
_StateInGuardcallsalgorithm._matches_in_state— the exactlogic backing the transition
inguard — so id / dotted-path / dict semantics stay consistentand there's no second implementation to drift.
_eval/_callgained an optionalconfigurationparameter, soand_("isAllowed", stateIn("#ready")),not_(stateIn(...)), etc.all evaluate correctly. Callables and string sub-guards are unaffected.
e305fdb("Fix roadmap PR checks", whichrode in on docs: architectural debt register + research-informed 0.7.0/0.8.0 roadmap #16) removed a
# type: ignore[index]. Restored here so this branch — and master —go green again.
Test plan
python3 -m pytest tests/ --ignore=tests/test_scxml.py→ 356 passed (8 new), 0 failuresruff check src/ tests/→ All checks passedmypy src/xstate/→ Success: no issues found in 21 source files (also un-breaks master)tests/test_scxml.py→ 53 failed, identical on clean master (pre-existing; thetest-frameworksubmodule isn't initialized in this environment). The change only passes anextra argument to composable guards and touches no SCXML algorithm function, so there is no
conformance regression.
🤖 Generated with Claude Code
Generated by Claude Code