Skip to content

Commit

Permalink
Add xstate.stop event and fix exit handlers execution (#3126)
Browse files Browse the repository at this point in the history
* Add `xstate.stop` event and fix exit handlers execution

* Make `state.value` of a stopped state an empty object

* Preserve last known `state.value` when service gets stopped

* Execute send actions from exit handlers of stopped/done machines

* Added changeset

* Make exit handlers of a machine that reached a final state be called with the last received event

* Tweak wording of the added changeset

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

Co-authored-by: David Khourshid <davidkpiano@gmail.com>
  • Loading branch information
Andarist and davidkpiano committed Aug 2, 2022
1 parent 186b254 commit 37b751c
Show file tree
Hide file tree
Showing 5 changed files with 577 additions and 56 deletions.
7 changes: 7 additions & 0 deletions .changeset/slow-cheetahs-try.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
---
'xstate': minor
---

All `exit` actions in the machine will now be correctly resolved and executed when a machine gets stopped or reaches its top-level final state. Previously, the actions were not correctly resolved and that was leading to runtime errors.

To implement this fix in a reliable way, a new internal event has been introduced: `{ type: 'xstate.stop' }` and when the machine stops its execution, all exit handlers of the current state (i.e. the active state nodes) will be called with that event. You should always assume that an exit handler might be called with that event.
41 changes: 30 additions & 11 deletions packages/core/src/StateNode.ts
Original file line number Diff line number Diff line change
Expand Up @@ -995,6 +995,8 @@ class StateNode<
}

private getActions(
resolvedConfig: Set<StateNode<any, any, any, any, any, any>>,
isDone: boolean,
transition: StateTransition<TContext, TEvent>,
currentContext: TContext,
_event: SCXML.Event<TEvent>,
Expand All @@ -1004,9 +1006,6 @@ class StateNode<
[],
prevState ? this.getStateNodes(prevState.value) : [this]
);
const resolvedConfig = transition.configuration.length
? getConfiguration(prevConfig, transition.configuration)
: prevConfig;

for (const sn of resolvedConfig) {
if (!has(prevConfig, sn) || has(transition.entrySet, sn.parent)) {
Expand Down Expand Up @@ -1087,6 +1086,23 @@ class StateNode<
this.machine.options.actions as any
) as Array<ActionObject<TContext, TEvent>>;

if (isDone) {
const stopActions = toActionObjects(
flatten(
[...resolvedConfig]
.sort((a, b) => b.order - a.order)
.map((stateNode) => stateNode.onExit)
),
this.machine.options.actions as any
).filter(
(action) =>
action.type !== actionTypes.raise &&
(action.type !== actionTypes.send ||
(!!action.to && action.to !== SpecialTargets.Internal))
);
return actions.concat(stopActions);
}

return actions;
}

Expand Down Expand Up @@ -1208,6 +1224,15 @@ class StateNode<
// - OR there are transitions
const willTransition =
!currentState || stateTransition.transitions.length > 0;

const resolvedConfiguration = willTransition
? stateTransition.configuration
: currentState
? currentState.configuration
: [];

const isDone = isInFinalState(resolvedConfiguration, this);

const resolvedStateValue = willTransition
? getValue(this.machine, configuration)
: undefined;
Expand All @@ -1219,6 +1244,8 @@ class StateNode<
: undefined
: undefined;
const actions = this.getActions(
new Set(resolvedConfiguration),
isDone,
stateTransition,
context,
_event,
Expand Down Expand Up @@ -1281,14 +1308,6 @@ class StateNode<
: ({} as Record<string, ActorRef<any>>)
);

const resolvedConfiguration = willTransition
? stateTransition.configuration
: currentState
? currentState.configuration
: [];

const isDone = isInFinalState(resolvedConfiguration, this);

const nextState = new State<
TContext,
TEvent,
Expand Down
2 changes: 1 addition & 1 deletion packages/core/src/actions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -624,7 +624,7 @@ export function choose<TContext, TEvent extends EventObject>(

export function resolveActions<TContext, TEvent extends EventObject>(
machine: StateNode<TContext, any, TEvent, any, any, any>,
currentState: State<TContext, TEvent> | undefined,
currentState: State<TContext, TEvent, any, any, any> | undefined,
currentContext: TContext,
_event: SCXML.Event<TEvent>,
actions: Array<ActionObject<TContext, TEvent>>,
Expand Down
160 changes: 117 additions & 43 deletions packages/core/src/interpreter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -38,7 +38,14 @@ import {
} from './types';
import { State, bindActionToState, isStateConfig } from './State';
import * as actionTypes from './actionTypes';
import { doneInvoke, error, getActionFunction, initEvent } from './actions';
import {
doneInvoke,
error,
getActionFunction,
initEvent,
resolveActions,
toActionObjects
} from './actions';
import { IS_PRODUCTION } from './environment';
import {
isPromiseLike,
Expand All @@ -57,11 +64,11 @@ import {
toObserver,
isActor,
isBehavior,
symbolObservable
symbolObservable,
flatten
} from './utils';
import { Scheduler } from './scheduler';
import { Actor, isSpawnedActor, createDeferredActor } from './Actor';
import { isInFinalState } from './stateUtils';
import { registry } from './registry';
import { getGlobal, registerService } from './devTools';
import * as serviceScope from './serviceScope';
Expand Down Expand Up @@ -341,12 +348,7 @@ export class Interpreter<
);
}

const isDone = isInFinalState(
state.configuration || [],
this.machine as any
);

if (this.state.configuration && isDone) {
if (this.state.done) {
// get final child state node
const finalChildStateNode = state.configuration.find(
(sn) => sn.type === 'final' && sn.parent === (this.machine as any)
Expand All @@ -360,7 +362,7 @@ export class Interpreter<
for (const listener of this.doneListeners) {
listener(doneInvoke(this.id, doneData));
}
this.stop();
this._stop();
}
}
/*
Expand Down Expand Up @@ -551,12 +553,7 @@ export class Interpreter<
});
return this;
}
/**
* Stops the interpreter and unsubscribe all listeners.
*
* This will also notify the `onStop` listeners.
*/
public stop(): this {
private _stop() {
for (const listener of this.listeners) {
this.listeners.delete(listener);
}
Expand All @@ -577,36 +574,101 @@ export class Interpreter<
return this;
}

[...this.state.configuration]
.sort((a, b) => b.order - a.order)
.forEach((stateNode) => {
for (const action of stateNode.definition.exit) {
this.exec(action, this.state);
}
});

// Stop all children
this.children.forEach((child) => {
if (isFunction(child.stop)) {
child.stop();
}
});
this.children.clear();
this.initialized = false;
this.status = InterpreterStatus.Stopped;
this._initialState = undefined;

// Cancel all delayed events
// we are going to stop within the current sync frame
// so we can safely just cancel this here as nothing async should be fired anyway
for (const key of Object.keys(this.delayedEventsMap)) {
this.clock.clearTimeout(this.delayedEventsMap[key]);
}

// clear everything that might be enqueued
this.scheduler.clear();

this.scheduler = new Scheduler({
deferEvents: this.options.deferEvents
});
}
/**
* Stops the interpreter and unsubscribe all listeners.
*
* This will also notify the `onStop` listeners.
*/
public stop(): this {
// TODO: add warning for stopping non-root interpreters

// grab the current scheduler as it will be replaced in _stop
const scheduler = this.scheduler;

this._stop();

// let what is currently processed to be finished
scheduler.schedule(() => {
// it feels weird to handle this here but we need to handle this even slightly "out of band"
const _event = toSCXMLEvent({ type: 'xstate.stop' }) as any;

const nextState = serviceScope.provide(this, () => {
const exitActions = flatten(
[...this.state.configuration]
.sort((a, b) => b.order - a.order)
.map((stateNode) =>
toActionObjects(
stateNode.onExit,
this.machine.options.actions as any
)
)
);

this.initialized = false;
this.status = InterpreterStatus.Stopped;
this._initialState = undefined;
registry.free(this.sessionId);
const [resolvedActions, updatedContext] = resolveActions(
this.machine as any,
this.state,
this.state.context,
_event,
exitActions,
this.machine.config.preserveActionOrder
);

const newState = new State<TContext, TEvent, TStateSchema, TTypestate>({
value: this.state.value,
context: updatedContext,
_event,
_sessionid: this.sessionId,
historyValue: undefined,
history: this.state,
actions: resolvedActions.filter(
(action) =>
action.type !== actionTypes.raise &&
(action.type !== actionTypes.send ||
(!!action.to && action.to !== SpecialTargets.Internal))
),
activities: {},
events: [],
configuration: [],
transitions: [],
children: {},
done: this.state.done,
tags: this.state.tags,
machine: this.machine
});
newState.changed = true;
return newState;
});

this.update(nextState, _event);

// TODO: think about converting those to actions
// Stop all children
this.children.forEach((child) => {
if (isFunction(child.stop)) {
child.stop();
}
});
this.children.clear();

registry.free(this.sessionId);
});

return this;
}
Expand Down Expand Up @@ -766,13 +828,22 @@ export class Interpreter<
}

if ('machine' in target) {
// Send SCXML events to machines
(target as AnyInterpreter).send({
...event,
name:
event.name === actionTypes.error ? `${error(this.id)}` : event.name,
origin: this.sessionId
});
// perhaps those events should be rejected in the parent
// but atm it doesn't have easy access to all of the information that is required to do it reliably
if (
this.status !== InterpreterStatus.Stopped ||
this.parent !== target ||
// we need to send events to the parent from exit handlers of a machine that reached its final state
this.state.done
) {
// Send SCXML events to machines
(target as AnyInterpreter).send({
...event,
name:
event.name === actionTypes.error ? `${error(this.id)}` : event.name,
origin: this.sessionId
});
}
} else {
// Send normal events to other targets
target.send(event.data);
Expand Down Expand Up @@ -1033,6 +1104,9 @@ export class Interpreter<
name: string,
options?: SpawnOptions
): ActorRef<any> {
if (this.status !== InterpreterStatus.Running) {
return createDeferredActor(entity, name);
}
if (isPromiseLike(entity)) {
return this.spawnPromise(Promise.resolve(entity), name);
} else if (isFunction(entity)) {
Expand Down
Loading

0 comments on commit 37b751c

Please sign in to comment.