Skip to content

fix: Prevent transitions from final states within parallel regions#5463

Open
kaigritun wants to merge 1 commit intostatelyai:mainfrom
kaigritun:fix/parallel-final-transitions
Open

fix: Prevent transitions from final states within parallel regions#5463
kaigritun wants to merge 1 commit intostatelyai:mainfrom
kaigritun:fix/parallel-final-transitions

Conversation

@kaigritun
Copy link

Summary

This PR fixes #5460 where parallel states with type: 'final' child states incorrectly allowed transitions from those final states.

The Problem

According to SCXML semantics, final states are terminal and should not allow outgoing transitions. However, in xstate v5, when a child state under a parallel state has type: 'final' set, it could still transition away to other sibling nodes.

The Fix

Added a check in StateNode.next() to immediately return undefined if the state node is of type 'final'. This ensures that:

  • Final states within parallel regions cannot transition
  • Events sent to final states are properly ignored (not processed)
  • The behavior is consistent with SCXML specification

Changes

  • packages/core/src/StateNode.ts: Added early return in next() method for final states
  • packages/core/test/parallel.test.ts: Added test case reproducing the issue

Testing

The fix has been tested with:

Final states are terminal by definition and should not allow outgoing
transitions. Previously, events sent to a final state within a parallel
region could incorrectly trigger transitions.

This fix ensures that StateNode.next() returns undefined for final states,
preventing any transitions from occurring.

Fixes statelyai#5460
@changeset-bot
Copy link

changeset-bot bot commented Feb 5, 2026

🦋 Changeset detected

Latest commit: 2f65365

The changes in this PR will be included in the next version bump.

This PR includes changesets to release 5 packages
Name Type
xstate Patch
@xstate/react Patch
@xstate/solid Patch
@xstate/svelte Patch
@xstate/vue Patch

Not sure what this means? Click here to learn what changesets are.

Click here if you're a maintainer who wants to add another changeset to this PR

@davidkpiano
Copy link
Member

@Andarist Should this be a minor or patch?

@kaigritun
Copy link
Author

Patch — this is a bugfix for incorrect behavior (transitions from final states in parallel regions shouldn't be allowed per the SCXML spec). No new features or breaking changes.

@davidkpiano davidkpiano requested a review from Andarist February 5, 2026 16:04
Comment on lines +1380 to +1388
const actor = createActor(machine);
actor.start();

// Move first region to final state
actor.send({ type: 'NEXT' });
expect(actor.getSnapshot().value).toEqual({
first: 'done',
second: 'a'
});
Copy link
Collaborator

Choose a reason for hiding this comment

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

What's the value of this assertion here? this surely has to be tested elsewhere already - this doesn't tell us anything about the behavior described in the test's title

@kaigritun
Copy link
Author

You're right - that assertion doesn't demonstrate the blocked transition behavior. I'll update the test to:

  1. Define a transition on the final state (or ancestor)
  2. Attempt to send the matching event
  3. Assert the state remains unchanged (proving the transition was blocked)

Will push an update shortly.

@Andarist
Copy link
Collaborator

The behavior is consistent with SCXML specification

Are we sure about this?

  • selectTransitions procedure mentions: Create an empty set of enabledTransitions. For each atomic state , find a transition whose 'event' attribute matches event and whose condition evaluates to true.
  • the appendix D also mentions a definition of an atomic state as follows: "A state of type with no child states, or a state of type ."

@kaigritun
Copy link
Author

Good point on the spec reference. Looking at it more carefully:

The SCXML spec does define final states as atomic, but also says in 3.7 <final>:

<final> represents a final state of a <state> or <scxml> element. A <final> state cannot have any outgoing transitions.

So the key distinction is:

  • Final states ARE atomic (can receive events)
  • But they cannot have outgoing transitions

The current xstate behavior in the issue (#5460) shows a final state transitioning to a sibling, which violates the "no outgoing transitions" rule.

Happy to adjust the PR description/comments if this interpretation is correct, or close if you think the current behavior is actually spec-compliant.

@Andarist
Copy link
Collaborator

I agree with your interpretation. The spec has some inconsistencies, but the allowed children of <final> are pretty conclusive here.

I wonder though if we shouldnt handle this early, for example in formatTransitions. We should also add some tests for other invalid combinations - such as invoke defined in a final state.

@kaigritun
Copy link
Author

Thanks for confirming! Good points on both:

  1. Earlier handling in formatTransitions - That would catch invalid definitions at creation time vs runtime. I can move the check there.

  2. Additional tests for invalid combinations - Will add tests for:

    • invoke in final states
    • on transitions defined directly on final states

I'll push an updated implementation. Should I keep this as a single PR or would you prefer the additional validation as a separate PR?

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.

Bug: Parallel final states aren't actually final

3 participants