Skip to content
Open
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
9 changes: 9 additions & 0 deletions .changeset/fix-parallel-final-transitions.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
---
'xstate': patch
---

Fix: Prevent transitions from final states within parallel regions

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 #5460
5 changes: 5 additions & 0 deletions packages/core/src/StateNode.ts
Original file line number Diff line number Diff line change
Expand Up @@ -376,6 +376,11 @@ export class StateNode<
>,
event: TEvent
): TransitionDefinition<TContext, TEvent>[] | undefined {
// Final states are terminal and cannot have outgoing transitions
if (this.type === 'final') {
return undefined;
}

const eventType = event.type;
const actions: UnknownAction[] = [];

Expand Down
95 changes: 95 additions & 0 deletions packages/core/test/parallel.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1343,4 +1343,99 @@ describe('parallel states', () => {

expect(flushTracked()).toEqual([]);
});

// https://github.com/statelyai/xstate/issues/5460
it('should not allow transitions from final states within parallel regions', () => {
const machine = createMachine({
id: 'test',
type: 'parallel',
states: {
first: {
initial: 'a',
states: {
a: {
on: {
NEXT: 'done'
}
},
done: {
type: 'final'
}
}
},
second: {
initial: 'a',
states: {
a: {
on: {
BACK: 'b'
}
},
b: {}
}
}
}
});

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'
});
Comment on lines +1380 to +1388
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


// Send event that would match on parent's 'on' if final was not respected
// The final state should not transition, even if there's a matching transition
// defined on an ancestor

// Create a machine where the final state has a transition defined (which should be ignored)
const machineWithTransitionOnFinal = createMachine({
id: 'test',
type: 'parallel',
states: {
first: {
initial: 'a',
states: {
a: {
on: {
NEXT: 'done'
}
},
done: {
type: 'final',
on: {
// This transition should never be taken because final states can't transition
INVALID: 'a'
}
}
}
},
second: {
initial: 'x',
states: {
x: {}
}
}
}
});

const actor2 = createActor(machineWithTransitionOnFinal);
actor2.start();

actor2.send({ type: 'NEXT' });
expect(actor2.getSnapshot().value).toEqual({
first: 'done',
second: 'x'
});

// Try to transition from the final state - should have no effect
actor2.send({ type: 'INVALID' });
expect(actor2.getSnapshot().value).toEqual({
first: 'done',
second: 'x'
});
});
});