Skip to content
10 changes: 10 additions & 0 deletions .changeset/nine-frogs-grow.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
---
'xstate': minor
---

A new [`predictableActionArguments`](https://xstate.js.org/docs/guides/actions.html) feature flag has been added that allows you to opt into some fixed behaviors that will be the default in v5. With this flag:

- XState will always call an action with the event directly responsible for the related transition,
- you also automatically opt-into [`preserveActionOrder`](https://xstate.js.org/docs/guides/context.html#action-order).

Please be aware that you might not able to use `state` from the `meta` argument when using this flag.
20 changes: 20 additions & 0 deletions docs/guides/actions.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,25 @@
# Actions

::: warning

It is advised to configure `predictableActionArguments: true` at the top-level of your machine config, like this:
Copy link
Member

Choose a reason for hiding this comment

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

Can you add the XState version this applies to?


```js
createMachine({
predictableActionArguments: true
// ...
});
```

This flag is an opt into some fixed behaviors that will be the default in v5. With this flag:

- XState will always call an action with the event directly responsible for the related transition,
- you also automatically opt-into [`preserveActionOrder`](https://xstate.js.org/docs/guides/context.html#action-order).

Please be aware that you might not able to use `state` from the `meta` argument when using this flag.

Choose a reason for hiding this comment

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

Can y'all add more information how to migrate? We had trouble finding a replacement for using this. Thanks!

Copy link
Collaborator Author

Choose a reason for hiding this comment

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

Could you open a discussion that would describe your use case for using this property?

Choose a reason for hiding this comment

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

Sure!

Choose a reason for hiding this comment

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

@Andarist Done :) #3511


:::

Actions are fire-and-forget [effects](./effects.md). They can be declared in three ways:

- `entry` actions are executed upon entering a state
Expand Down
9 changes: 9 additions & 0 deletions packages/core/src/Machine.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,9 @@ import {
TypegenDisabled,
ResolveTypegenMeta
} from './typegenTypes';
import { IS_PRODUCTION } from './environment';

let warned = false;

/**
* @deprecated Use `createMachine(...)` instead.
Expand Down Expand Up @@ -139,5 +142,11 @@ export function createMachine<
TServiceMap,
TTypesMeta
> {
if (!IS_PRODUCTION && !config.predictableActionArguments && !warned) {
warned = true;
console.warn(
'It is highly recommended to set `predictableActionArguments` to `true` when using `createMachine`. https://xstate.js.org/docs/guides/actions.html'
);
}
return new StateNode(config, options as any) as any;
}
42 changes: 30 additions & 12 deletions packages/core/src/StateNode.ts
Original file line number Diff line number Diff line change
Expand Up @@ -69,7 +69,8 @@ import {
InternalMachineOptions,
ServiceMap,
StateConfig,
AnyStateMachine
AnyStateMachine,
PredictableActionArgumentsExec
} from './types';
import { matchesState } from './utils';
import { State, stateValuesEqual } from './State';
Expand Down Expand Up @@ -1119,9 +1120,11 @@ class StateNode<
| State<TContext, TEvent, any, TTypestate, TResolvedTypesMeta> = this
.initialState,
event: Event<TEvent> | SCXML.Event<TEvent>,
context?: TContext
context?: TContext,
exec?: PredictableActionArgumentsExec
): State<TContext, TEvent, TStateSchema, TTypestate, TResolvedTypesMeta> {
const _event = toSCXMLEvent(event);

let currentState: State<
TContext,
TEvent,
Expand Down Expand Up @@ -1185,6 +1188,7 @@ class StateNode<
stateTransition,
currentState,
currentState.context,
exec,
_event
);
}
Expand All @@ -1198,11 +1202,17 @@ class StateNode<
TResolvedTypesMeta
>,
_event: SCXML.Event<TEvent> | NullEvent,
originalEvent: SCXML.Event<TEvent>
originalEvent: SCXML.Event<TEvent>,
predictableExec?: PredictableActionArgumentsExec
): State<TContext, TEvent, TStateSchema, TTypestate, TResolvedTypesMeta> {
const currentActions = state.actions;

state = this.transition(state, _event as SCXML.Event<TEvent>);
state = this.transition(
state,
_event as SCXML.Event<TEvent>,
undefined,
predictableExec
);
// Save original event to state
// TODO: this should be the raised event! Delete in V5 (breaking)
state._event = originalEvent;
Expand All @@ -1216,9 +1226,11 @@ class StateNode<
stateTransition: StateTransition<TContext, TEvent>,
currentState: State<TContext, TEvent, any, any, any> | undefined,
context: TContext,
predictableExec?: PredictableActionArgumentsExec,
_event: SCXML.Event<TEvent> = initEvent as SCXML.Event<TEvent>
): State<TContext, TEvent, TStateSchema, TTypestate, TResolvedTypesMeta> {
const { configuration } = stateTransition;

// Transition will "apply" if:
// - this is the initial state (there is no current state)
// - OR there are transitions
Expand Down Expand Up @@ -1268,7 +1280,9 @@ class StateNode<
context,
_event,
actions,
this.machine.config.preserveActionOrder
predictableExec,
this.machine.config.predictableActionArguments ||
this.machine.config.preserveActionOrder
);

const [raisedEvents, nonRaisedActions] = partition(
Expand Down Expand Up @@ -1358,7 +1372,7 @@ class StateNode<

// There are transient transitions if the machine is not in a final state
// and if some of the state nodes have transient ("always") transitions.
const isTransient =
const hasAlwaysTransitions =
!isDone &&
(this._transient ||
configuration.some((stateNode) => {
Expand All @@ -1370,24 +1384,27 @@ class StateNode<
// because an transient transition should be triggered even if there are no
// enabled transitions.
//
// If we're already working on an transient transition (by checking
// if the event is a NULL_EVENT), then stop to prevent an infinite loop.
// If we're already working on an transient transition then stop to prevent an infinite loop.
//
// Otherwise, if there are no enabled nor transient transitions, we are done.
if (!willTransition && (!isTransient || _event.name === NULL_EVENT)) {
if (
!willTransition &&
(!hasAlwaysTransitions || _event.name === NULL_EVENT)
) {
return nextState;
}

let maybeNextState = nextState;

if (!isDone) {
if (isTransient) {
if (hasAlwaysTransitions) {
maybeNextState = this.resolveRaisedTransition(
maybeNextState,
{
type: actionTypes.nullEvent
},
_event
_event,
predictableExec
);
}

Expand All @@ -1396,7 +1413,8 @@ class StateNode<
maybeNextState = this.resolveRaisedTransition(
maybeNextState,
raisedEvent._event,
_event
_event,
predictableExec
);
}
}
Expand Down
34 changes: 25 additions & 9 deletions packages/core/src/actions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -39,7 +39,8 @@ import {
StopActionObject,
Cast,
EventFrom,
AnyActorRef
AnyActorRef,
PredictableActionArgumentsExec
} from './types';
import * as actionTypes from './actionTypes';
import {
Expand Down Expand Up @@ -628,6 +629,7 @@ export function resolveActions<TContext, TEvent extends EventObject>(
currentContext: TContext,
_event: SCXML.Event<TEvent>,
actions: Array<ActionObject<TContext, TEvent>>,
predictableExec?: PredictableActionArgumentsExec,
preserveActionOrder: boolean = false
): [Array<ActionObject<TContext, TEvent>>, TContext] {
const [assignActions, otherActions] = preserveActionOrder
Expand All @@ -650,15 +652,16 @@ export function resolveActions<TContext, TEvent extends EventObject>(
otherActions
.map((actionObject) => {
switch (actionObject.type) {
case actionTypes.raise:
case actionTypes.raise: {
return resolveRaise(actionObject as RaiseAction<TEvent>);
}
case actionTypes.send:
const sendAction = resolveSend(
actionObject as SendAction<TContext, TEvent, AnyEventObject>,
updatedContext,
_event,
machine.options.delays as any
) as ActionObject<TContext, TEvent>; // TODO: fix ActionTypes.Init
) as SendActionObject<TContext, TEvent>; // TODO: fix ActionTypes.Init

if (!IS_PRODUCTION) {
// warn after resolving as we can create better contextual message here
Expand All @@ -670,13 +673,20 @@ export function resolveActions<TContext, TEvent extends EventObject>(
);
}

if (sendAction.to !== SpecialTargets.Internal) {
predictableExec?.(sendAction, updatedContext, _event);
}

return sendAction;
case actionTypes.log:
return resolveLog(
case actionTypes.log: {
const resolved = resolveLog(
actionObject as LogAction<TContext, TEvent>,
updatedContext,
_event
);
predictableExec?.(resolved, updatedContext, _event);
return resolved;
}
case actionTypes.choose: {
const chooseAction = actionObject as ChooseAction<TContext, TEvent>;
const matchedActions = chooseAction.conds.find((condition) => {
Expand All @@ -691,7 +701,7 @@ export function resolveActions<TContext, TEvent extends EventObject>(
guard,
updatedContext,
_event,
currentState as any
(!predictableExec ? currentState : undefined) as any
)
);
})?.actions;
Expand All @@ -712,6 +722,7 @@ export function resolveActions<TContext, TEvent extends EventObject>(
toArray(matchedActions),
machine.options.actions as any
),
predictableExec,
preserveActionOrder
);
updatedContext = resolvedContextFromChoose;
Expand All @@ -735,25 +746,28 @@ export function resolveActions<TContext, TEvent extends EventObject>(
toArray(matchedActions),
machine.options.actions as any
),
predictableExec,
preserveActionOrder
);
updatedContext = resolvedContext;
preservedContexts?.push(updatedContext);
return resolvedActionsFromPure;
}
case actionTypes.stop: {
return resolveStop(
const resolved = resolveStop(
actionObject as StopAction<TContext, TEvent>,
updatedContext,
_event
);
predictableExec?.(resolved, updatedContext, _event);
Copy link
Collaborator Author

Choose a reason for hiding this comment

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

This leads to mutating this.state.children where this.state has not yet been updated with the new state. I need to figure out how I can avoid this problem.

Copy link
Collaborator Author

Choose a reason for hiding this comment

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

I think this is OK given the current state of things - .children are already mutated and somewhat unstable: https://codesandbox.io/s/keen-cookies-vfte0i?file=/src/index.js:798-972

I think though that this should be fixed in v5

return resolved;
}
case actionTypes.assign: {
updatedContext = updateContext(
updatedContext,
_event,
[actionObject as AssignAction<TContext, TEvent>],
currentState
!predictableExec ? currentState : undefined
);
preservedContexts?.push(updatedContext);
break;
Expand All @@ -764,7 +778,9 @@ export function resolveActions<TContext, TEvent extends EventObject>(
machine.options.actions as any
);
const { exec } = resolvedActionObject;
if (exec && preservedContexts) {
if (predictableExec) {
predictableExec(resolvedActionObject, updatedContext, _event);
} else if (exec && preservedContexts) {
const contextIndex = preservedContexts.length - 1;
resolvedActionObject = {
...resolvedActionObject,
Expand Down
Loading