Skip to content

Commit

Permalink
Make it impossible to exit a root state (#3487)
Browse files Browse the repository at this point in the history
* Fix restarted root-level invocations

* Add changesets

* Make it impossible to exit a root state

* Update .changeset/purple-buses-hug.md

Co-authored-by: David Khourshid <davidkpiano@gmail.com>

* Add test for root entry/exit actions not being called on root external transitions

Co-authored-by: David Khourshid <davidkpiano@gmail.com>
  • Loading branch information
Andarist and davidkpiano committed Sep 12, 2022
1 parent 74fbf64 commit 1b6e3df
Show file tree
Hide file tree
Showing 5 changed files with 110 additions and 28 deletions.
8 changes: 8 additions & 0 deletions .changeset/purple-buses-hug.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
---
'xstate': patch
---

author: @Andarist
author: @davidkpiano

Make it impossible to exit a root state. For example, this means that root-level transitions specified as external transitions will no longer restart root-level invocations. See [#3072](https://github.com/statelyai/xstate/issues/3072) for more details.
2 changes: 1 addition & 1 deletion docs/about/resources.md
Original file line number Diff line number Diff line change
Expand Up @@ -43,7 +43,7 @@ Explore a collection of professionally-designed state machines and statecharts i

## Articles

- [Building iOS Stopwatch functionality using XState](https://blog.lakbychance.com/building-ios-stopwatch-functionality-using-xstate) by [Lakshya Thakur](https://hashnode.com/@lakbychance) 2022-07-31
- [Building iOS Stopwatch functionality using XState](https://blog.lakbychance.com/building-ios-stopwatch-functionality-using-xstate) by [Lakshya Thakur](https://hashnode.com/@lakbychance) 2022-07-31
- [Using State Machines in Front-End Development](https://blog.picnic.nl/using-state-machines-in-front-end-development-c875ea1d5322) by [Danielle Richter](https://medium.com/@danielle.richter) 2021-10-27
- [Quick post: Modeling a video player with XState](https://dev.to/matiasfha/quick-post-modeling-a-video-player-with-xstate-eko) by [Matías Hernández Arellano](https://twitter.com/cafe_contech) on 2021-10-25
- [Getting Started with XState, React and Typescript (Part 2)](https://moduscreate.com/blog/getting-started-with-xstate-react-and-typescript-part-2/) by [Santiago Kent](https://twitter.com/moduscreate) on 2021-10-18
Expand Down
59 changes: 32 additions & 27 deletions packages/core/src/stateUtils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -931,15 +931,20 @@ function getPathFromRootToNode<
TC extends MachineContext,
TE extends EventObject
>(stateNode: StateNode<TC, TE>): Array<StateNode<TC, TE>> {
const path: Array<StateNode<TC, TE>> = [];
let marker = stateNode.parent;

if (!marker) {
return [stateNode];
}

const path: Array<typeof stateNode> = [];

while (marker) {
path.unshift(marker);
path.push(marker);
marker = marker.parent;
}

return path;
return path.reverse();
}

function hasIntersection<T>(s1: Iterable<T>, s2: Iterable<T>): boolean {
Expand Down Expand Up @@ -1000,25 +1005,6 @@ export function removeConflictingTransitions<
return Array.from(filteredTransitions);
}

function findLCCA<TContext extends MachineContext, TEvent extends EventObject>(
stateNodes: Array<StateNode<TContext, TEvent>>
): StateNode<TContext, TEvent> {
const [head] = stateNodes;

let current = getPathFromRootToNode(head);
let candidates: Array<StateNode<TContext, TEvent>> = [];

stateNodes.forEach((stateNode) => {
const path = getPathFromRootToNode(stateNode);

candidates = current.filter((sn) => path.includes(sn));
current = candidates;
candidates = [];
});

return current[current.length - 1];
}

function getEffectiveTargetStates<
TC extends MachineContext,
TE extends EventObject
Expand Down Expand Up @@ -1080,9 +1066,25 @@ function getTransitionDomain<
return transition.source;
}

const lcca = findLCCA(targetStates.concat(transition.source));
const involvedStates = targetStates.concat(transition.source);
const [head] = involvedStates;

let current = getPathFromRootToNode(head);

for (let i = 1; i < involvedStates.length; i++) {
const path = getPathFromRootToNode(involvedStates[i]);
current = current.filter((sn) => path.includes(sn));
}

const domain = current[current.length - 1];

return lcca;
if (!IS_PRODUCTION && !domain) {
throw new Error(
'No transition domain could be found for an external transition. This error is likely caused by a bug in XState. Please file an issue.'
);
}

return domain;
}

function exitStates<
Expand Down Expand Up @@ -1123,13 +1125,13 @@ export function enterStates<
>(
transitions: Array<TransitionDefinition<TContext, TEvent>>,
mutConfiguration: Set<StateNode<TContext, TEvent>>,
state: State<TContext, TEvent>
state: State<TContext, TEvent>,
mutStatesToEnter: Set<StateNode<TContext, TEvent>>
) {
const statesToInvoke: typeof mutConfiguration = new Set();
const internalQueue: Array<SCXML.Event<TEvent>> = [];

const actions: BaseActionObject[] = [];
const mutStatesToEnter = new Set<StateNode<TContext, TEvent>>();
const mutStatesForDefaultEntry = new Set<StateNode<TContext, TEvent>>();

computeEntrySet(
Expand Down Expand Up @@ -1402,6 +1404,7 @@ export function microstep<
mutConfiguration: Set<StateNode<TContext, TEvent>>,
machine: StateMachine<TContext, TEvent>,
_event: SCXML.Event<TEvent>,
mutStatesToEnter: Set<StateNode<TContext, TEvent>>,
predictableExec?: PredictableActionArgumentsExec
): {
actions: BaseActionObject[];
Expand Down Expand Up @@ -1442,7 +1445,8 @@ export function microstep<
const res = enterStates(
filteredTransitions,
mutConfiguration,
currentState || State.from({})
currentState || State.from({}),
mutStatesToEnter
);

// Start invocations
Expand Down Expand Up @@ -1594,6 +1598,7 @@ export function resolveMicroTransition<
new Set(prevConfig),
machine,
_event,
new Set(!currentState._initial ? [] : [machine.root]),
predictableExec
);

Expand Down
34 changes: 34 additions & 0 deletions packages/core/test/actions.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -416,6 +416,40 @@ describe('entry/exit actions', () => {
expect(actual).toEqual(['loaded entry']);
});

it('root entry/exit actions should not be called on root external transitions', () => {
let entrySpy = jest.fn();
let exitSpy = jest.fn();

const machine = createMachine({
id: 'root',
entry: entrySpy,
exit: exitSpy,
on: {
EVENT: {
target: '#two',
internal: false
}
},
initial: 'one',
states: {
one: {},
two: {
id: 'two'
}
}
});

const service = interpret(machine).start();

entrySpy.mockClear();
exitSpy.mockClear();

service.send({ type: 'EVENT' });

expect(entrySpy).not.toHaveBeenCalled();
expect(exitSpy).not.toHaveBeenCalled();
});

describe('should ignore same-parent state actions (sparse)', () => {
const fooBar = {
initial: 'foo',
Expand Down
35 changes: 35 additions & 0 deletions packages/core/test/invoke.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3146,6 +3146,41 @@ describe('invoke', () => {

expect(invokeCount).toBe(2);
});

// https://github.com/statelyai/xstate/issues/3072
it('root invocations should not restart on root external transitions', () => {
let count = 0;

const machine = createMachine({
id: 'root',
invoke: {
src: () =>
fromPromise(() => {
count++;
return Promise.resolve(42);
})
},
on: {
EVENT: {
target: '#two',
internal: false
}
},
initial: 'one',
states: {
one: {},
two: {
id: 'two'
}
}
});

const service = interpret(machine).start();

service.send({ type: 'EVENT' });

expect(count).toEqual(1);
});
});

describe('actors option', () => {
Expand Down

0 comments on commit 1b6e3df

Please sign in to comment.