Skip to content

feat: stateIn() guard (0.7.0) - #20

Merged
JovaniPink merged 1 commit into
masterfrom
claude/0.7.0-statein-guard
Jun 29, 2026
Merged

feat: stateIn() guard (0.7.0)#20
JovaniPink merged 1 commit into
masterfrom
claude/0.7.0-statein-guard

Conversation

@JovaniPink

Copy link
Copy Markdown
Owner

Summary

Second 0.7.0 feature — XState v5's stateIn guard. We already had in_state matching
internally 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_.

from xstate import Machine, and_, stateIn

machine = Machine(config, guards={
    # passes only when the machine is in #ready AND the user is allowed
    "canSubmit": and_("isAllowed", stateIn("#ready")),
})

# or inline on a transition
"on": {"GO": {"target": "active", "guard": stateIn("switch.on")}}

spec accepts the same shapes as a transition in guard: "#id", a dotted "parent.child"
path, or a {parent: child} dict.

What changed

File Change
src/xstate/guards.py New _StateInGuard + stateIn(spec). Threaded the active configuration through _ComposableGuard._eval / _call so a stateIn nested inside and_/or_/not_ can see it. A direct call with no configuration returns False.
src/xstate/algorithm.py condition_match() passes configuration into composable-guard evaluation (one extra arg — no SCXML algorithm function touched)
src/xstate/__init__.py Export stateIn
src/xstate/machine.py Restore the # type: ignore[index] on line 145 that commit e305fdb dropped (it had left master mypy-red)
tests/test_state_in_guard.py 8 new tests
CLAUDE.md Record stateIn in the "working" feature list

Design notes

  • Reuses the existing matcher: _StateInGuard calls algorithm._matches_in_state — the exact
    logic backing the transition in guard — so id / dotted-path / dict semantics stay consistent
    and there's no second implementation to drift.
  • Composition works because configuration is threaded: _eval/_call gained an optional
    configuration parameter, so and_("isAllowed", stateIn("#ready")), not_(stateIn(...)), etc.
    all evaluate correctly. Callables and string sub-guards are unaffected.
  • Drive-by fix: master is currently mypy-red because e305fdb ("Fix roadmap PR checks", which
    rode 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.py356 passed (8 new), 0 failures
  • ruff check src/ tests/ → All checks passed
  • mypy src/xstate/ → Success: no issues found in 21 source files (also un-breaks master)
  • SCXML: tests/test_scxml.py53 failed, identical on clean master (pre-existing; the
    test-framework submodule isn't initialized in this environment). The change only passes an
    extra argument to composable guards and touches no SCXML algorithm function, so there is no
    conformance regression.

🤖 Generated with Claude Code


Generated by Claude Code

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.
Copilot AI review requested due to automatic review settings June 29, 2026 00:16
@JovaniPink
JovaniPink merged commit 36c2433 into master Jun 29, 2026
3 of 4 checks passed

@gemini-code-assist gemini-code-assist Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

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.

Comment thread src/xstate/guards.py
Comment on lines 63 to 67
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))

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

high

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"

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

medium

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"

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

💡 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".

Comment thread src/xstate/guards.py
Comment on lines 63 to +64
if isinstance(guard, _ComposableGuard):
return guard._call(context, event, registry)
return guard._call(context, event, registry, configuration)

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 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 👍 / 👎.

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

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) in guards.py and threads configuration through composable guard evaluation so nested stateIn works correctly.
  • Updates algorithm.condition_match() to pass the active configuration into composable guard execution.
  • Exports stateIn publicly 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.

Comment thread src/xstate/guards.py
Comment on lines +140 to +144
if configuration is None:
return False
from xstate.algorithm import _matches_in_state

return _matches_in_state(self._spec, configuration)
Comment thread src/xstate/guards.py
Comment on lines +165 to +166
*spec* is ``"#id"``, a dotted ``"parent.child"`` path, or a ``{parent: child}``
dict — the same shape accepted by a transition ``in`` guard.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants