diff --git a/.changeset/lemon-needles-play.md b/.changeset/lemon-needles-play.md new file mode 100644 index 0000000000..7a4db02a9b --- /dev/null +++ b/.changeset/lemon-needles-play.md @@ -0,0 +1,23 @@ +--- +'xstate': minor +--- + +Added a new `transition` function that takes an actor logic, a snapshot, and an event, and returns a tuple containing the next snapshot and the actions to execute. This function is a pure function and does not execute the actions itself. It can be used like this: + +```ts +import { transition } from 'xstate'; + +const [nextState, actions] = transition(actorLogic, currentState, event); +// Execute actions as needed +``` + +Added a new `initialTransition` function that takes an actor logic and an optional input, and returns a tuple containing the initial snapshot and the actions to execute from the initial transition. This function is also a pure function and does not execute the actions itself. It can be used like this: + +```ts +import { initialTransition } from 'xstate'; + +const [initialState, actions] = initialTransition(actorLogic, input); +// Execute actions as needed +``` + +These new functions provide a way to separate the calculation of the next snapshot and actions from the execution of those actions, allowing for more control and flexibility in the transition process. diff --git a/.vscode/launch.json b/.vscode/launch.json index 5b53f85bc2..f0d52db74d 100644 --- a/.vscode/launch.json +++ b/.vscode/launch.json @@ -11,7 +11,7 @@ "console": "integratedTerminal", "internalConsoleOptions": "neverOpen", "windows": { - "program": "${workspaceFolder}/node_modules/jest/bin/jest" + "program": "${workspaceFolder}/node_modules/jest/bin/jest.js" } }, { @@ -30,7 +30,7 @@ "console": "integratedTerminal", "internalConsoleOptions": "neverOpen", "windows": { - "program": "${workspaceFolder}/node_modules/jest/bin/jest" + "program": "${workspaceFolder}/node_modules/jest/bin/jest.js" } } ] diff --git a/README.md b/README.md index a7f00e8ac0..8a5ab89b2a 100644 --- a/README.md +++ b/README.md @@ -181,16 +181,16 @@ Read [📽 the slides](http://slides.com/davidkhourshid/finite-state-machines) ( ## Packages -| Package | Description | -| --------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------- | -| 🤖 `xstate` | Core finite state machine and statecharts library + interpreter | -| [📉 `@xstate/graph`](https://github.com/statelyai/xstate/tree/main/packages/xstate-graph) | Graph traversal and model-based testing utilities using XState | -| [⚛️ `@xstate/react`](https://github.com/statelyai/xstate/tree/main/packages/xstate-react) | React hooks and utilities for using XState in React applications | -| [💚 `@xstate/vue`](https://github.com/statelyai/xstate/tree/main/packages/xstate-vue) | Vue composition functions and utilities for using XState in Vue applications | -| [🎷 `@xstate/svelte`](https://github.com/statelyai/xstate/tree/main/packages/xstate-svelte) | Svelte utilities for using XState in Svelte applications | -| [🥏 `@xstate/solid`](https://github.com/statelyai/xstate/tree/main/packages/xstate-solid) | Solid hooks and utilities for using XState in Solid applications | -| [🔍 `@statelyai/inspect`](https://github.com/statelyai/inspect) | Inspection utilities for XState | -| [🏪 `@xstate/store`](https://github.com/statelyai/xstate/tree/main/packages/xstate-store) | Small library for simple state management | +| Package | Description | +| ------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------- | +| 🤖 `xstate` | Core finite state machine and statecharts library + interpreter | +| [📉 `@xstate/graph`](https://github.com/statelyai/xstate/tree/main/packages/xstate-graph) | Graph traversal and model-based testing utilities using XState | +| [⚛️ `@xstate/react`](https://github.com/statelyai/xstate/tree/main/packages/xstate-react) | React hooks and utilities for using XState in React applications | +| [💚 `@xstate/vue`](https://github.com/statelyai/xstate/tree/main/packages/xstate-vue) | Vue composition functions and utilities for using XState in Vue applications | +| [🎷 `@xstate/svelte`](https://github.com/statelyai/xstate/tree/main/packages/xstate-svelte) | Svelte utilities for using XState in Svelte applications | +| [🥏 `@xstate/solid`](https://github.com/statelyai/xstate/tree/main/packages/xstate-solid) | Solid hooks and utilities for using XState in Solid applications | +| [🔍 `@statelyai/inspect`](https://github.com/statelyai/inspect) | Inspection utilities for XState | +| [🏪 `@xstate/store`](https://github.com/statelyai/xstate/tree/main/packages/xstate-store) | Small library for simple state management | ## Finite State Machines diff --git a/jest.config.js b/jest.config.js index daef5f79a9..8f17310f08 100644 --- a/jest.config.js +++ b/jest.config.js @@ -3,7 +3,7 @@ const { constants } = require('jest-config'); /** @type {import('@jest/types').Config.InitialOptions} */ module.exports = { prettierPath: null, - setupFilesAfterEnv: ['@xstate-repo/jest-utils/setup'], + setupFilesAfterEnv: ['/scripts/jest-utils/setup'], transform: { [constants.DEFAULT_JS_PATTERN]: 'babel-jest', '^.+\\.vue$': '@vue/vue3-jest', diff --git a/packages/core/src/StateMachine.ts b/packages/core/src/StateMachine.ts index c4ce523bfb..171872c4fb 100644 --- a/packages/core/src/StateMachine.ts +++ b/packages/core/src/StateMachine.ts @@ -293,7 +293,8 @@ export class StateMachine< TMeta, TConfig > { - return macrostep(snapshot, event, actorScope).snapshot as typeof snapshot; + return macrostep(snapshot, event, actorScope, []) + .snapshot as typeof snapshot; } /** @@ -328,7 +329,7 @@ export class StateMachine< TConfig > > { - return macrostep(snapshot, event, actorScope).microstates; + return macrostep(snapshot, event, actorScope, []).microstates; } public getTransitionData( @@ -386,7 +387,8 @@ export class StateMachine< initEvent, actorScope, [assign(assignment)], - internalQueue + internalQueue, + undefined ) as SnapshotFrom; } diff --git a/packages/core/src/StateNode.ts b/packages/core/src/StateNode.ts index 039f32db4e..9c7c764cdc 100644 --- a/packages/core/src/StateNode.ts +++ b/packages/core/src/StateNode.ts @@ -4,6 +4,7 @@ import { NULL_EVENT, STATE_DELIMITER } from './constants.ts'; import { evaluateGuard } from './guards.ts'; import { memo } from './memo.ts'; import { + BuiltinAction, formatInitialTransition, formatTransition, formatTransitions, @@ -47,7 +48,7 @@ const toSerializableAction = (action: UnknownAction) => { } if (typeof action === 'function') { if ('resolve' in action) { - return { type: (action as any).type }; + return { type: (action as BuiltinAction).type }; } return { type: action.name @@ -296,13 +297,14 @@ export class StateNode< toArray(this.config.invoke).map((invokeConfig, i) => { const { src, systemId } = invokeConfig; const resolvedId = invokeConfig.id ?? createInvokeId(this.id, i); - const resolvedSrc = + const sourceName = typeof src === 'string' ? src : `xstate.invoke.${createInvokeId(this.id, i)}`; + return { ...invokeConfig, - src: resolvedSrc, + src: sourceName, id: resolvedId, systemId: systemId, toJSON() { @@ -310,7 +312,7 @@ export class StateNode< return { ...invokeDefValues, type: 'xstate.invoke', - src: resolvedSrc, + src: sourceName, id: resolvedId }; } diff --git a/packages/core/src/actions/assign.ts b/packages/core/src/actions/assign.ts index 076237df0c..7144d6e22f 100644 --- a/packages/core/src/actions/assign.ts +++ b/packages/core/src/actions/assign.ts @@ -1,7 +1,7 @@ import isDevelopment from '#is-development'; import { cloneMachineSnapshot } from '../State.ts'; +import { executingCustomAction } from '../createActor.ts'; import { Spawner, createSpawner } from '../spawn.ts'; -import { executingCustomAction } from '../stateUtils.ts'; import type { ActionArgs, AnyActorScope, @@ -15,7 +15,8 @@ import type { ParameterizedObject, PropertyAssigner, ProvidedActor, - ActionFunction + ActionFunction, + BuiltinActionResolution } from '../types.ts'; export interface AssignArgs< @@ -39,7 +40,7 @@ function resolveAssign( | Assigner | PropertyAssigner; } -) { +): BuiltinActionResolution { if (!snapshot.context) { throw new Error( 'Cannot assign to undefined `context`. Ensure that `context` is defined in the machine config.' @@ -83,7 +84,9 @@ function resolveAssign( ...spawnedChildren } : snapshot.children - }) + }), + undefined, + undefined ]; } diff --git a/packages/core/src/actions/cancel.ts b/packages/core/src/actions/cancel.ts index 96d36993ad..2447f757c4 100644 --- a/packages/core/src/actions/cancel.ts +++ b/packages/core/src/actions/cancel.ts @@ -5,7 +5,8 @@ import { EventObject, MachineContext, ActionArgs, - ParameterizedObject + ParameterizedObject, + BuiltinActionResolution } from '../types.ts'; type ResolvableSendId< @@ -26,15 +27,15 @@ function resolveCancel( actionArgs: ActionArgs, actionParams: ParameterizedObject['params'] | undefined, { sendId }: { sendId: ResolvableSendId } -) { +): BuiltinActionResolution { const resolvedSendId = typeof sendId === 'function' ? sendId(actionArgs, actionParams) : sendId; - return [snapshot, resolvedSendId]; + return [snapshot, { sendId: resolvedSendId }, undefined]; } -function executeCancel(actorScope: AnyActorScope, resolvedSendId: string) { +function executeCancel(actorScope: AnyActorScope, params: { sendId: string }) { actorScope.defer(() => { - actorScope.system.scheduler.cancel(actorScope.self, resolvedSendId); + actorScope.system.scheduler.cancel(actorScope.self, params.sendId); }); } diff --git a/packages/core/src/actions/emit.ts b/packages/core/src/actions/emit.ts index 94d27bf7be..e0e56f2868 100644 --- a/packages/core/src/actions/emit.ts +++ b/packages/core/src/actions/emit.ts @@ -1,5 +1,5 @@ import isDevelopment from '#is-development'; -import { executingCustomAction } from '../stateUtils.ts'; +import { executingCustomAction } from '../createActor.ts'; import { ActionArgs, ActionFunction, @@ -10,7 +10,8 @@ import { EventObject, MachineContext, ParameterizedObject, - SendExpr + SendExpr, + BuiltinActionResolution } from '../types.ts'; function resolveEmit( @@ -31,12 +32,12 @@ function resolveEmit( EventObject >; } -) { +): BuiltinActionResolution { const resolvedEvent = typeof eventOrExpr === 'function' ? eventOrExpr(args, actionParams) : eventOrExpr; - return [snapshot, { event: resolvedEvent }]; + return [snapshot, { event: resolvedEvent }, undefined]; } function executeEmit( diff --git a/packages/core/src/actions/enqueueActions.ts b/packages/core/src/actions/enqueueActions.ts index a1d2c83993..4757f59627 100644 --- a/packages/core/src/actions/enqueueActions.ts +++ b/packages/core/src/actions/enqueueActions.ts @@ -12,6 +12,7 @@ import { MachineContext, ParameterizedObject, ProvidedActor, + BuiltinActionResolution, UnifiedArg } from '../types.ts'; import { assign } from './assign.ts'; @@ -130,7 +131,7 @@ function resolveEnqueueActions( EventObject >; } -) { +): BuiltinActionResolution { const actions: any[] = []; const enqueue: Parameters[0]['enqueue'] = function enqueue( action diff --git a/packages/core/src/actions/log.ts b/packages/core/src/actions/log.ts index a4552a2e49..e762a79574 100644 --- a/packages/core/src/actions/log.ts +++ b/packages/core/src/actions/log.ts @@ -6,7 +6,8 @@ import { EventObject, LogExpr, MachineContext, - ParameterizedObject + ParameterizedObject, + BuiltinActionResolution } from '../types.ts'; type ResolvableLogValue< @@ -28,14 +29,15 @@ function resolveLog( value: ResolvableLogValue; label: string | undefined; } -) { +): BuiltinActionResolution { return [ snapshot, { value: typeof value === 'function' ? value(actionArgs, actionParams) : value, label - } + }, + undefined ]; } diff --git a/packages/core/src/actions/raise.ts b/packages/core/src/actions/raise.ts index 0afd349a08..5d8e699cdf 100644 --- a/packages/core/src/actions/raise.ts +++ b/packages/core/src/actions/raise.ts @@ -1,5 +1,5 @@ import isDevelopment from '#is-development'; -import { executingCustomAction } from '../stateUtils.ts'; +import { executingCustomAction } from '../createActor.ts'; import { ActionArgs, ActionFunction, @@ -9,10 +9,12 @@ import { DelayExpr, DoNotInfer, EventObject, + ExecutableActionObject, MachineContext, ParameterizedObject, RaiseActionOptions, - SendExpr + SendExpr, + BuiltinActionResolution } from '../types.ts'; function resolveRaise( @@ -47,7 +49,7 @@ function resolveRaise( | undefined; }, { internalQueue }: { internalQueue: AnyEventObject[] } -) { +): BuiltinActionResolution { const delaysMap = snapshot.machine.implementations.delays; if (typeof eventOrExpr === 'string') { @@ -75,7 +77,15 @@ function resolveRaise( if (typeof resolvedDelay !== 'number') { internalQueue.push(resolvedEvent); } - return [snapshot, { event: resolvedEvent, id, delay: resolvedDelay }]; + return [ + snapshot, + { + event: resolvedEvent, + id, + delay: resolvedDelay + }, + undefined + ]; } function executeRaise( @@ -168,3 +178,12 @@ export function raise< return raise; } + +export interface ExecutableRaiseAction extends ExecutableActionObject { + type: 'xstate.raise'; + params: { + event: EventObject; + id: string | undefined; + delay: number | undefined; + }; +} diff --git a/packages/core/src/actions/send.ts b/packages/core/src/actions/send.ts index 433cefeb57..3d5c3917a7 100644 --- a/packages/core/src/actions/send.ts +++ b/packages/core/src/actions/send.ts @@ -1,7 +1,7 @@ import isDevelopment from '#is-development'; import { XSTATE_ERROR } from '../constants.ts'; import { createErrorActorEvent } from '../eventUtils.ts'; -import { executingCustomAction } from '../stateUtils.ts'; +import { executingCustomAction } from '../createActor.ts'; import { ActionArgs, ActionFunction, @@ -14,11 +14,13 @@ import { DoNotInfer, EventFrom, EventObject, + ExecutableActionObject, InferEvent, MachineContext, ParameterizedObject, SendExpr, SendToActionOptions, + BuiltinActionResolution, SpecialTargets, UnifiedArg } from '../types.ts'; @@ -63,7 +65,7 @@ function resolveSendTo( | undefined; }, extra: { deferredActorIds: string[] | undefined } -) { +): BuiltinActionResolution { const delaysMap = snapshot.machine.implementations.delays; if (typeof eventOrExpr === 'string') { @@ -120,7 +122,14 @@ function resolveSendTo( return [ snapshot, - { to: targetActorRef, event: resolvedEvent, id, delay: resolvedDelay } + { + to: targetActorRef, + targetId: typeof resolvedTarget === 'string' ? resolvedTarget : undefined, + event: resolvedEvent, + id, + delay: resolvedDelay + }, + undefined ]; } @@ -241,7 +250,7 @@ export function sendTo< > { if (isDevelopment && executingCustomAction) { console.warn( - 'Custom actions should not call `raise()` directly, as it is not imperative. See https://stately.ai/docs/actions#built-in-actions for more details.' + 'Custom actions should not call `sendTo()` directly, as it is not imperative. See https://stately.ai/docs/actions#built-in-actions for more details.' ); } @@ -254,7 +263,7 @@ export function sendTo< } } - sendTo.type = 'xsnapshot.sendTo'; + sendTo.type = 'xstate.sendTo'; sendTo.to = to; sendTo.event = eventOrExpr; sendTo.id = options?.id; @@ -365,3 +374,13 @@ export function forwardTo< TUsedDelay >(target, ({ event }: any) => event, options); } + +export interface ExecutableSendToAction extends ExecutableActionObject { + type: 'xstate.sendTo'; + params: { + event: EventObject; + id: string | undefined; + delay: number | undefined; + to: AnyActorRef; + }; +} diff --git a/packages/core/src/actions/spawnChild.ts b/packages/core/src/actions/spawnChild.ts index d2b7eaf78e..6070a8cc7f 100644 --- a/packages/core/src/actions/spawnChild.ts +++ b/packages/core/src/actions/spawnChild.ts @@ -18,6 +18,7 @@ import { ParameterizedObject, ProvidedActor, RequiredActorOptions, + BuiltinActionResolution, UnifiedArg } from '../types.ts'; import { resolveReferencedActor } from '../utils.ts'; @@ -47,30 +48,31 @@ function resolveSpawn( input?: unknown; syncSnapshot: boolean; } -) { +): BuiltinActionResolution { const logic = typeof src === 'string' ? resolveReferencedActor(snapshot.machine, src) : src; const resolvedId = typeof id === 'function' ? id(actionArgs) : id; - let actorRef: AnyActorRef | undefined; + let resolvedInput: unknown | undefined = undefined; if (logic) { + resolvedInput = + typeof input === 'function' + ? input({ + context: snapshot.context, + event: actionArgs.event, + self: actorScope.self + }) + : input; actorRef = createActor(logic, { id: resolvedId, src, parent: actorScope.self, syncSnapshot, systemId, - input: - typeof input === 'function' - ? input({ - context: snapshot.context, - event: actionArgs.event, - self: actorScope.self - }) - : input + input: resolvedInput }); } @@ -89,8 +91,12 @@ function resolveSpawn( }), { id, - actorRef - } + systemId, + actorRef, + src, + input: resolvedInput + }, + undefined ]; } @@ -172,17 +178,18 @@ type SpawnArguments< TExpressionEvent extends EventObject, TEvent extends EventObject, TActor extends ProvidedActor -> = IsLiteralString extends true - ? DistributeActors - : [ - src: string | AnyActorLogic, - options?: { - id?: ResolvableActorId; - systemId?: string; - input?: unknown; - syncSnapshot?: boolean; - } - ]; +> = + IsLiteralString extends true + ? DistributeActors + : [ + src: string | AnyActorLogic, + options?: { + id?: ResolvableActorId; + systemId?: string; + input?: unknown; + syncSnapshot?: boolean; + } + ]; export function spawnChild< TContext extends MachineContext, @@ -215,7 +222,7 @@ export function spawnChild< } } - spawnChild.type = 'snapshot.spawnChild'; + spawnChild.type = 'xstate.spawnChild'; spawnChild.id = id; spawnChild.systemId = systemId; spawnChild.src = src; diff --git a/packages/core/src/actions/stopChild.ts b/packages/core/src/actions/stopChild.ts index 18b7f48c31..3fe268b8fe 100644 --- a/packages/core/src/actions/stopChild.ts +++ b/packages/core/src/actions/stopChild.ts @@ -8,7 +8,8 @@ import { AnyMachineSnapshot, EventObject, MachineContext, - ParameterizedObject + ParameterizedObject, + BuiltinActionResolution } from '../types.ts'; type ResolvableActorRef< @@ -30,7 +31,7 @@ function resolveStop( args: ActionArgs, actionParams: ParameterizedObject['params'] | undefined, { actorRef }: { actorRef: ResolvableActorRef } -) { +): BuiltinActionResolution { const actorRefOrString = typeof actorRef === 'function' ? actorRef(args, actionParams) : actorRef; const resolvedActorRef: AnyActorRef | undefined = @@ -47,7 +48,8 @@ function resolveStop( cloneMachineSnapshot(snapshot, { children }), - resolvedActorRef + resolvedActorRef, + undefined ]; } function executeStop( diff --git a/packages/core/src/actors/transition.ts b/packages/core/src/actors/transition.ts index 0706aef460..5b29a14361 100644 --- a/packages/core/src/actors/transition.ts +++ b/packages/core/src/actors/transition.ts @@ -196,11 +196,7 @@ export function fromTransition< transition: (snapshot, event, actorScope) => { return { ...snapshot, - context: transition( - snapshot.context, - event, - actorScope as any - ) + context: transition(snapshot.context, event, actorScope as any) }; }, getInitialSnapshot: (_, input) => { diff --git a/packages/core/src/createActor.ts b/packages/core/src/createActor.ts index ebbc9a4ea1..0b0467fef2 100644 --- a/packages/core/src/createActor.ts +++ b/packages/core/src/createActor.ts @@ -11,6 +11,8 @@ import { reportUnhandledError } from './reportUnhandledError.ts'; import { symbolObservable } from './symbolObservable.ts'; import { AnyActorSystem, Clock, createSystem } from './system.ts'; +export let executingCustomAction: boolean = false; + import type { ActorScope, AnyActorLogic, @@ -183,13 +185,40 @@ export class Actor if (!listeners && !wildcardListener) { return; } - const allListeners = new Set([ + const allListeners = [ ...(listeners ? listeners.values() : []), ...(wildcardListener ? wildcardListener.values() : []) - ]); - for (const handler of Array.from(allListeners)) { + ]; + for (const handler of allListeners) { handler(emittedEvent); } + }, + actionExecutor: (action) => { + const exec = () => { + this._actorScope.system._sendInspectionEvent({ + type: '@xstate.action', + actorRef: this, + action: { + type: action.type, + params: action.params + } + }); + if (!action.exec) { + return; + } + const saveExecutingCustomAction = executingCustomAction; + try { + executingCustomAction = true; + action.exec(action.info, action.params); + } finally { + executingCustomAction = saveExecutingCustomAction; + } + }; + if (this._processingStatus === ProcessingStatus.Running) { + exec(); + } else { + this._deferred.push(exec); + } } }; diff --git a/packages/core/src/getNextSnapshot.ts b/packages/core/src/getNextSnapshot.ts index 7f57aaab0d..3e30b1a8f9 100644 --- a/packages/core/src/getNextSnapshot.ts +++ b/packages/core/src/getNextSnapshot.ts @@ -27,12 +27,14 @@ export function createInertActorScope( sessionId: '', stopChild: () => {}, system: self.system, - emit: () => {} + emit: () => {}, + actionExecutor: () => {} }; return inertActorScope; } +/** @deprecated Use `initialTransition(…)` instead. */ export function getInitialSnapshot( actorLogic: T, ...[input]: undefined extends InputFrom @@ -50,6 +52,7 @@ export function getInitialSnapshot( * If the `snapshot` is `undefined`, the initial snapshot of the `actorLogic` is * used. * + * @deprecated Use `transition(…)` instead. * @example * * ```ts diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 5bac3ac28d..3690d330e6 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -33,6 +33,7 @@ export { pathToStateValue, toObserver } from './utils.ts'; +export { transition, initialTransition } from './transition.ts'; export { waitFor } from './waitFor.ts'; declare global { diff --git a/packages/core/src/inspection.ts b/packages/core/src/inspection.ts index f8b81381e1..8a31484eb7 100644 --- a/packages/core/src/inspection.ts +++ b/packages/core/src/inspection.ts @@ -41,7 +41,7 @@ export interface InspectedActionEvent extends BaseInspectionEventProperties { type: '@xstate.action'; action: { type: string; - params: Record; + params: unknown; }; } diff --git a/packages/core/src/stateUtils.ts b/packages/core/src/stateUtils.ts index 659abbf831..3b01a64baf 100644 --- a/packages/core/src/stateUtils.ts +++ b/packages/core/src/stateUtils.ts @@ -34,10 +34,10 @@ import { TODO, UnknownAction, ParameterizedObject, - ActionFunction, AnyTransitionConfig, - ProvidedActor, - AnyActorScope + AnyActorScope, + ActionExecutor, + AnyStateMachine } from './types.ts'; import { resolveOutput, @@ -47,7 +47,6 @@ import { toTransitionConfigArray, isErrorActorEvent } from './utils.ts'; -import { ProcessingStatus } from './createActor.ts'; type StateNodeIterable< TContext extends MachineContext, @@ -279,7 +278,13 @@ export function getDelayedTransitions( const mutateEntryExit = (delay: string | number) => { const afterEvent = createAfterEvent(delay, stateNode.id); const eventType = afterEvent.type; - stateNode.entry.push(raise(afterEvent, { id: eventType, delay })); + + stateNode.entry.push( + raise(afterEvent, { + id: eventType, + delay + }) + ); stateNode.exit.push(cancel(eventType)); return eventType; }; @@ -326,6 +331,7 @@ export function formatTransition( `State "${stateNode.id}" has declared \`cond\` for one of its transitions. This property has been renamed to \`guard\`. Please update your code.` ); } + const transition = { ...transitionConfig, actions: toArray(transitionConfig.actions), @@ -1005,7 +1011,8 @@ export function microstep( filteredTransitions, mutStateNodeSet, historyValue, - internalQueue + internalQueue, + actorScope.actionExecutor ); } @@ -1015,7 +1022,8 @@ export function microstep( event, actorScope, filteredTransitions.flatMap((t) => t.actions), - internalQueue + internalQueue, + undefined ); // Enter states @@ -1040,7 +1048,8 @@ export function microstep( nextStateNodes .sort((a, b) => b.order - a.order) .flatMap((state) => state.exit), - internalQueue + internalQueue, + undefined ); } @@ -1408,7 +1417,8 @@ function exitStates( transitions: AnyTransitionDefinition[], mutStateNodeSet: Set, historyValue: HistoryValue, - internalQueue: AnyEventObject[] + internalQueue: AnyEventObject[], + _actionExecutor: ActionExecutor ) { let nextSnapshot = currentSnapshot; const statesToExit = computeExitSet( @@ -1445,15 +1455,17 @@ function exitStates( event, actorScope, [...s.exit, ...s.invoke.map((def) => stopChild(def.id))], - internalQueue + internalQueue, + undefined ); mutStateNodeSet.delete(s); } return [nextSnapshot, changedHistory || historyValue] as const; } -interface BuiltinAction { +export interface BuiltinAction { (): void; + type: `xstate.${string}`; resolve: ( actorScope: AnyActorScope, snapshot: AnyMachineSnapshot, @@ -1474,9 +1486,9 @@ interface BuiltinAction { execute: (actorScope: AnyActorScope, params: unknown) => void; } -export let executingCustomAction: - | ActionFunction - | false = false; +function getAction(machine: AnyStateMachine, actionType: string) { + return machine.implementations.actions[actionType]; +} function resolveAndExecuteActionsWithContext( currentSnapshot: AnyMachineSnapshot, @@ -1499,27 +1511,8 @@ function resolveAndExecuteActionsWithContext( : // the existing type of `.actions` assumes non-nullable `TExpressionAction` // it's fine to cast this here to get a common type and lack of errors in the rest of the code // our logic below makes sure that we call those 2 "variants" correctly - ( - machine.implementations.actions as Record< - string, - ActionFunction< - MachineContext, - EventObject, - EventObject, - ParameterizedObject['params'] | undefined, - ProvidedActor, - ParameterizedObject, - ParameterizedObject, - string, - EventObject - > - > - )[typeof action === 'string' ? action : action.type]; - - if (!resolvedAction) { - continue; - } + getAction(machine, typeof action === 'string' ? action : action.type); const actionArgs = { context: intermediateSnapshot.context, event, @@ -1536,36 +1529,18 @@ function resolveAndExecuteActionsWithContext( : action.params : undefined; - function executeAction() { - actorScope.system._sendInspectionEvent({ - type: '@xstate.action', - actorRef: actorScope.self, - action: { - type: - typeof action === 'string' - ? action - : typeof action === 'object' - ? action.type - : action.name || '(anonymous)', - params: actionParams - } + if (!resolvedAction || !('resolve' in resolvedAction)) { + actorScope.actionExecutor({ + type: + typeof action === 'string' + ? action + : typeof action === 'object' + ? action.type + : action.name || '(anonymous)', + info: actionArgs, + params: actionParams, + exec: resolvedAction }); - try { - executingCustomAction = resolvedAction; - resolvedAction(actionArgs, actionParams); - } finally { - executingCustomAction = false; - } - } - - if (!('resolve' in resolvedAction)) { - if (actorScope.self._processingStatus === ProcessingStatus.Running) { - executeAction(); - } else { - actorScope.defer(() => { - executeAction(); - }); - } continue; } @@ -1586,11 +1561,12 @@ function resolveAndExecuteActionsWithContext( } if ('execute' in builtinAction) { - if (actorScope.self._processingStatus === ProcessingStatus.Running) { - builtinAction.execute(actorScope, params); - } else { - actorScope.defer(builtinAction.execute.bind(null, actorScope, params)); - } + actorScope.actionExecutor({ + type: builtinAction.type, + info: actionArgs, + params, + exec: builtinAction.execute.bind(null, actorScope, params) + }); } if (actions) { @@ -1614,7 +1590,7 @@ export function resolveActionsAndContext( actorScope: AnyActorScope, actions: UnknownAction[], internalQueue: AnyEventObject[], - deferredActorIds?: string[] + deferredActorIds: string[] | undefined ): AnyMachineSnapshot { const retries: (readonly [BuiltinAction, unknown])[] | undefined = deferredActorIds ? [] : undefined; @@ -1636,7 +1612,7 @@ export function macrostep( snapshot: AnyMachineSnapshot, event: EventObject, actorScope: AnyActorScope, - internalQueue: AnyEventObject[] = [] + internalQueue: AnyEventObject[] ): { snapshot: typeof snapshot; microstates: Array; @@ -1766,7 +1742,8 @@ function stopChildren( event, actorScope, Object.values(nextState.children).map((child: any) => stopChild(child)), - [] + [], + undefined ); } diff --git a/packages/core/src/system.ts b/packages/core/src/system.ts index cb0402c768..1ff355cc05 100644 --- a/packages/core/src/system.ts +++ b/packages/core/src/system.ts @@ -161,8 +161,8 @@ export function createSystem( ...event, rootId: rootActor.sessionId }; - inspectionObservers.forEach( - (observer) => observer.next?.(resolvedInspectionEvent) + inspectionObservers.forEach((observer) => + observer.next?.(resolvedInspectionEvent) ); }; diff --git a/packages/core/src/transition.ts b/packages/core/src/transition.ts new file mode 100644 index 0000000000..6466c9436a --- /dev/null +++ b/packages/core/src/transition.ts @@ -0,0 +1,59 @@ +import { createInertActorScope } from './getNextSnapshot'; +import { + AnyActorLogic, + EventFromLogic, + InputFrom, + SnapshotFrom, + ExecutableActionsFrom +} from './types'; + +/** + * Given actor `logic`, a `snapshot`, and an `event`, returns a tuple of the + * `nextSnapshot` and `actions` to execute. + * + * This is a pure function that does not execute `actions`. + */ +export function transition( + logic: T, + snapshot: SnapshotFrom, + event: EventFromLogic +): [nextSnapshot: SnapshotFrom, actions: ExecutableActionsFrom[]] { + const executableActions = [] as ExecutableActionsFrom[]; + + const actorScope = createInertActorScope(logic); + actorScope.actionExecutor = (action) => { + executableActions.push(action as ExecutableActionsFrom); + }; + + const nextSnapshot = logic.transition(snapshot, event, actorScope); + + return [nextSnapshot, executableActions]; +} + +/** + * Given actor `logic` and optional `input`, returns a tuple of the + * `nextSnapshot` and `actions` to execute from the initial transition (no + * previous state). + * + * This is a pure function that does not execute `actions`. + */ +export function initialTransition( + logic: T, + ...[input]: undefined extends InputFrom + ? [input?: InputFrom] + : [input: InputFrom] +): [SnapshotFrom, ExecutableActionsFrom[]] { + const executableActions = [] as ExecutableActionsFrom[]; + + const actorScope = createInertActorScope(logic); + actorScope.actionExecutor = (action) => { + executableActions.push(action as ExecutableActionsFrom); + }; + + const nextSnapshot = logic.getInitialSnapshot( + actorScope, + input + ) as SnapshotFrom; + + return [nextSnapshot, executableActions]; +} diff --git a/packages/core/src/types.ts b/packages/core/src/types.ts index 624d75c194..1ec3f3a2e0 100644 --- a/packages/core/src/types.ts +++ b/packages/core/src/types.ts @@ -8,6 +8,8 @@ import type { Actor, ProcessingStatus } from './createActor.ts'; import { Spawner } from './spawn.ts'; import { AnyActorSystem, Clock } from './system.js'; import { InspectionEvent } from './inspection.ts'; +import { ExecutableRaiseAction } from './actions/raise.ts'; +import { ExecutableSendToAction } from './actions/send.ts'; export type Identity = { [K in keyof T]: T[K] }; @@ -1983,7 +1985,11 @@ export interface ActorRef< ) => Subscription; } -export type AnyActorRef = ActorRef; +export type AnyActorRef = ActorRef< + any, + any, // TODO: shouldn't this be AnyEventObject? + any +>; export type ActorRefLike = Pick< AnyActorRef, @@ -2157,6 +2163,7 @@ export interface ActorScope< emit: (event: TEmitted) => void; system: TSystem; stopChild: (child: AnyActorRef) => void; + actionExecutor: ActionExecutor; } export type AnyActorScope = ActorScope< @@ -2208,17 +2215,17 @@ export interface ActorLogic< /** The initial setup/configuration used to create the actor logic. */ config?: unknown; /** - * Transition function that processes the current state and an incoming - * message to produce a new state. + * Transition function that processes the current state and an incoming event + * to produce a new state. * * @param snapshot - The current state. - * @param message - The incoming message. + * @param event - The incoming event. * @param actorScope - The actor scope. * @returns The new state. */ transition: ( snapshot: TSnapshot, - message: TEvent, + event: TEvent, actorScope: ActorScope ) => TSnapshot; /** @@ -2604,3 +2611,65 @@ export type ToStateValue = T extends { > : never) : {}; + +export interface ExecutableActionObject { + type: string; + info: ActionArgs; + params: NonReducibleUnknown; + exec: + | ((info: ActionArgs, params: unknown) => void) + | undefined; +} + +export interface ToExecutableAction + extends ExecutableActionObject { + type: T['type']; + params: T['params']; + exec: undefined; +} + +export interface ExecutableSpawnAction extends ExecutableActionObject { + type: 'xstate.spawnChild'; + info: ActionArgs; + params: { + id: string; + actorRef: AnyActorRef | undefined; + src: string | AnyActorLogic; + }; +} + +// TODO: cover all that can be actually returned +export type SpecialExecutableAction = + | ExecutableSpawnAction + | ExecutableRaiseAction + | ExecutableSendToAction; + +export type ExecutableActionsFrom = + T extends StateMachine< + infer _TContext, + infer _TEvent, + infer _TChildren, + infer _TActor, + infer TAction, + infer _TGuard, + infer _TDelay, + infer _TStateValue, + infer _TTag, + infer _TInput, + infer _TOutput, + infer _TEmitted, + infer _TMeta, + infer _TConfig + > + ? + | SpecialExecutableAction + | (string extends TAction['type'] ? never : ToExecutableAction) + : never; + +export type ActionExecutor = (actionToExecute: ExecutableActionObject) => void; + +export type BuiltinActionResolution = [ + AnyMachineSnapshot, + NonReducibleUnknown, // params + UnknownAction[] | undefined +]; diff --git a/packages/core/test/actions.test.ts b/packages/core/test/actions.test.ts index 389404bfeb..3a8213337b 100644 --- a/packages/core/test/actions.test.ts +++ b/packages/core/test/actions.test.ts @@ -7,6 +7,7 @@ import { raise, sendParent, sendTo, + spawnChild, stopChild } from '../src/actions.ts'; import { CallbackActorRef, fromCallback } from '../src/actors/callback.ts'; @@ -3178,6 +3179,190 @@ describe('sendTo', () => { ] `); }); + + it('a self-event "handler" of an event sent using sendTo should be able to read updated snapshot of self', () => { + const spy = jest.fn(); + const machine = createMachine({ + context: { + counter: 0 + }, + initial: 'a', + states: { + a: { + on: { NEXT: 'b' } + }, + b: { + entry: [ + assign({ counter: 1 }), + sendTo(({ self }) => self, { type: 'EVENT' }) + ], + on: { + EVENT: { + actions: ({ self }) => spy(self.getSnapshot().context), + target: 'c' + } + } + }, + c: {} + } + }); + + const actorRef = createActor(machine).start(); + + actorRef.send({ type: 'NEXT' }); + actorRef.send({ type: 'EVENT' }); + + expect(spy).toMatchMockCallsInlineSnapshot(` +[ + [ + { + "counter": 1, + }, + ], +] +`); + }); + + it("should not attempt to deliver a delayed event to the spawned actor's ID that was stopped since the event was scheduled", async () => { + const spy1 = jest.fn(); + + const child1 = createMachine({ + on: { + PING: { + actions: spy1 + } + } + }); + + const spy2 = jest.fn(); + + const child2 = createMachine({ + on: { + PING: { + actions: spy2 + } + } + }); + + const machine = setup({ + actors: { + child1, + child2 + } + }).createMachine({ + initial: 'a', + states: { + a: { + on: { + START: 'b' + } + }, + b: { + entry: [ + spawnChild('child1', { + id: 'myChild' + }), + sendTo('myChild', { type: 'PING' }, { delay: 1 }), + stopChild('myChild'), + spawnChild('child2', { + id: 'myChild' + }) + ] + } + } + }); + + const actorRef = createActor(machine).start(); + + actorRef.send({ type: 'START' }); + + await sleep(10); + + expect(spy1).toHaveBeenCalledTimes(0); + expect(spy2).toHaveBeenCalledTimes(0); + + expect(console.warn).toMatchMockCallsInlineSnapshot(` +[ + [ + "Event "PING" was sent to stopped actor "myChild (x:113)". This actor has already reached its final state, and will not transition. +Event: {"type":"PING"}", + ], +] +`); + }); + + it("should not attempt to deliver a delayed event to the invoked actor's ID that was stopped since the event was scheduled", async () => { + const spy1 = jest.fn(); + + const child1 = createMachine({ + on: { + PING: { + actions: spy1 + } + } + }); + + const spy2 = jest.fn(); + + const child2 = createMachine({ + on: { + PING: { + actions: spy2 + } + } + }); + + const machine = setup({ + actors: { + child1, + child2 + } + }).createMachine({ + initial: 'a', + states: { + a: { + on: { + START: 'b' + } + }, + b: { + entry: sendTo('myChild', { type: 'PING' }, { delay: 1 }), + invoke: { + src: 'child1', + id: 'myChild' + }, + on: { + NEXT: 'c' + } + }, + c: { + invoke: { + src: 'child2', + id: 'myChild' + } + } + } + }); + + const actorRef = createActor(machine).start(); + + actorRef.send({ type: 'START' }); + actorRef.send({ type: 'NEXT' }); + + await sleep(10); + + expect(spy1).toHaveBeenCalledTimes(0); + expect(spy2).toHaveBeenCalledTimes(0); + + expect(console.warn).toMatchMockCallsInlineSnapshot(` +[ + [ + "Event "PING" was sent to stopped actor "myChild (x:116)". This actor has already reached its final state, and will not transition. +Event: {"type":"PING"}", + ], +] +`); + }); }); describe('raise', () => { @@ -3215,7 +3400,7 @@ describe('raise', () => { service.send({ type: 'TO_B' }); }); - it('should be able to send a delayed event to itself with delay = 0', (done) => { + it('should be able to send a delayed event to itself with delay = 0', async () => { const machine = createMachine({ initial: 'a', states: { @@ -3239,11 +3424,9 @@ describe('raise', () => { // The state should not be changed yet; `delay: 0` is equivalent to `setTimeout(..., 0)` expect(service.getSnapshot().value).toEqual('a'); - setTimeout(() => { - // The state should be changed now - expect(service.getSnapshot().value).toEqual('b'); - done(); - }); + await sleep(0); + // The state should be changed now + expect(service.getSnapshot().value).toEqual('b'); }); it('should be able to raise an event and respond to it in the same state', () => { @@ -3542,6 +3725,97 @@ describe('cancel', () => { expect(spy.mock.calls.length).toBe(0); }); + + it('should be able to cancel a just scheduled delayed event to a just invoked child', async () => { + const spy = jest.fn(); + + const child = createMachine({ + on: { + PING: { + actions: spy + } + } + }); + + const machine = setup({ + actors: { + child + } + }).createMachine({ + initial: 'a', + states: { + a: { + on: { + START: 'b' + } + }, + b: { + entry: [ + sendTo('myChild', { type: 'PING' }, { id: 'myEvent', delay: 0 }), + cancel('myEvent') + ], + invoke: { + src: 'child', + id: 'myChild' + } + } + } + }); + + const actorRef = createActor(machine).start(); + + actorRef.send({ + type: 'START' + }); + + await sleep(10); + expect(spy.mock.calls.length).toBe(0); + }); + + it('should not be able to cancel a just scheduled non-delayed event to a just invoked child', async () => { + const spy = jest.fn(); + + const child = createMachine({ + on: { + PING: { + actions: spy + } + } + }); + + const machine = setup({ + actors: { + child + } + }).createMachine({ + initial: 'a', + states: { + a: { + on: { + START: 'b' + } + }, + b: { + entry: [ + sendTo('myChild', { type: 'PING' }, { id: 'myEvent' }), + cancel('myEvent') + ], + invoke: { + src: 'child', + id: 'myChild' + } + } + } + }); + + const actorRef = createActor(machine).start(); + + actorRef.send({ + type: 'START' + }); + + expect(spy.mock.calls.length).toBe(1); + }); }); describe('assign action order', () => { @@ -3562,7 +3836,9 @@ describe('assign action order', () => { ] }); - createActor(machine).start(); + const actor = createActor(machine).start(); + + expect(actor.getSnapshot().context).toEqual({ count: 2 }); expect(captured).toEqual([0, 1, 2]); }); @@ -4039,7 +4315,7 @@ describe('actions', () => { "Custom actions should not call \`raise()\` directly, as it is not imperative. See https://stately.ai/docs/actions#built-in-actions for more details.", ], [ - "Custom actions should not call \`raise()\` directly, as it is not imperative. See https://stately.ai/docs/actions#built-in-actions for more details.", + "Custom actions should not call \`sendTo()\` directly, as it is not imperative. See https://stately.ai/docs/actions#built-in-actions for more details.", ], [ "Custom actions should not call \`emit()\` directly, as it is not imperative. See https://stately.ai/docs/actions#built-in-actions for more details.", @@ -4047,4 +4323,19 @@ describe('actions', () => { ] `); }); + + it('inline actions should not leak into provided actions object', async () => { + const actions = {}; + + const machine = createMachine( + { + entry: () => {} + }, + { actions } + ); + + createActor(machine).start(); + + expect(actions).toEqual({}); + }); }); diff --git a/packages/core/test/actor.test.ts b/packages/core/test/actor.test.ts index d21488fc00..65ff942554 100644 --- a/packages/core/test/actor.test.ts +++ b/packages/core/test/actor.test.ts @@ -1753,4 +1753,53 @@ describe('actors', () => { actor.start(); actor.send({ type: 'event' }); }); + + it('same-position invokes should not leak between machines', async () => { + const spy = jest.fn(); + + const sharedActors = {}; + + const m1 = createMachine( + { + invoke: { + src: fromPromise(async () => 'foo'), + onDone: { + actions: ({ event }) => spy(event.output) + } + } + }, + { actors: sharedActors } + ); + + createMachine( + { + invoke: { src: fromPromise(async () => 100) } + }, + { actors: sharedActors } + ); + + createActor(m1).start(); + + await sleep(1); + + expect(spy).toHaveBeenCalledTimes(1); + expect(spy).toHaveBeenCalledWith('foo'); + }); + + it('inline invokes should not leak into provided actors object', async () => { + const actors = {}; + + const machine = createMachine( + { + invoke: { + src: fromPromise(async () => 'foo') + } + }, + { actors } + ); + + createActor(machine).start(); + + expect(actors).toEqual({}); + }); }); diff --git a/packages/core/test/deterministic.test.ts b/packages/core/test/deterministic.test.ts index bdaafbaaa6..e7b4756925 100644 --- a/packages/core/test/deterministic.test.ts +++ b/packages/core/test/deterministic.test.ts @@ -1,7 +1,7 @@ import { fromCallback, createActor, - getNextSnapshot, + transition, createMachine, getInitialSnapshot } from '../src/index.ts'; @@ -69,13 +69,13 @@ describe('deterministic machine', () => { describe('machine transitions', () => { it('should properly transition states based on event-like object', () => { expect( - getNextSnapshot( + transition( lightMachine, lightMachine.resolveState({ value: 'green' }), { type: 'TIMER' } - ).value + )[0].value ).toEqual('yellow'); }); @@ -104,7 +104,7 @@ describe('deterministic machine', () => { it('should throw an error if not given an event', () => { expect(() => - getNextSnapshot( + transition( lightMachine, testMachine.resolveState({ value: 'red' }), undefined as any @@ -114,9 +114,9 @@ describe('deterministic machine', () => { it('should transition to nested states as target', () => { expect( - getNextSnapshot(testMachine, testMachine.resolveState({ value: 'a' }), { + transition(testMachine, testMachine.resolveState({ value: 'a' }), { type: 'T' - }).value + })[0].value ).toEqual({ b: 'b1' }); @@ -124,37 +124,31 @@ describe('deterministic machine', () => { it('should throw an error for transitions from invalid states', () => { expect(() => - getNextSnapshot( - testMachine, - testMachine.resolveState({ value: 'fake' }), - { type: 'T' } - ) + transition(testMachine, testMachine.resolveState({ value: 'fake' }), { + type: 'T' + }) ).toThrow(); }); it('should throw an error for transitions from invalid substates', () => { expect(() => - getNextSnapshot( - testMachine, - testMachine.resolveState({ value: 'a.fake' }), - { - type: 'T' - } - ) + transition(testMachine, testMachine.resolveState({ value: 'a.fake' }), { + type: 'T' + }) ).toThrow(); }); it('should use the machine.initialState when an undefined state is given', () => { const init = getInitialSnapshot(lightMachine, undefined); expect( - getNextSnapshot(lightMachine, init, { type: 'TIMER' }).value + transition(lightMachine, init, { type: 'TIMER' })[0].value ).toEqual('yellow'); }); it('should use the machine.initialState when an undefined state is given (unhandled event)', () => { const init = getInitialSnapshot(lightMachine, undefined); expect( - getNextSnapshot(lightMachine, init, { type: 'TIMER' }).value + transition(lightMachine, init, { type: 'TIMER' })[0].value ).toEqual('yellow'); }); }); @@ -162,23 +156,19 @@ describe('deterministic machine', () => { describe('machine transition with nested states', () => { it('should properly transition a nested state', () => { expect( - getNextSnapshot( + transition( lightMachine, lightMachine.resolveState({ value: { red: 'walk' } }), { type: 'PED_COUNTDOWN' } - ).value + )[0].value ).toEqual({ red: 'wait' }); }); it('should transition from initial nested states', () => { expect( - getNextSnapshot( - lightMachine, - lightMachine.resolveState({ value: 'red' }), - { - type: 'PED_COUNTDOWN' - } - ).value + transition(lightMachine, lightMachine.resolveState({ value: 'red' }), { + type: 'PED_COUNTDOWN' + })[0].value ).toEqual({ red: 'wait' }); @@ -186,13 +176,9 @@ describe('deterministic machine', () => { it('should transition from deep initial nested states', () => { expect( - getNextSnapshot( - lightMachine, - lightMachine.resolveState({ value: 'red' }), - { - type: 'PED_COUNTDOWN' - } - ).value + transition(lightMachine, lightMachine.resolveState({ value: 'red' }), { + type: 'PED_COUNTDOWN' + })[0].value ).toEqual({ red: 'wait' }); @@ -200,11 +186,11 @@ describe('deterministic machine', () => { it('should bubble up events that nested states cannot handle', () => { expect( - getNextSnapshot( + transition( lightMachine, lightMachine.resolveState({ value: { red: 'stop' } }), { type: 'TIMER' } - ).value + )[0].value ).toEqual('green'); }); @@ -238,13 +224,13 @@ describe('deterministic machine', () => { it('should transition to the deepest initial state', () => { expect( - getNextSnapshot( + transition( lightMachine, lightMachine.resolveState({ value: 'yellow' }), { type: 'TIMER' } - ).value + )[0].value ).toEqual({ red: 'walk' }); @@ -252,10 +238,10 @@ describe('deterministic machine', () => { it('should return the same state if no transition occurs', () => { const init = getInitialSnapshot(lightMachine, undefined); - const initialState = getNextSnapshot(lightMachine, init, { + const [initialState] = transition(lightMachine, init, { type: 'NOTHING' }); - const nextState = getNextSnapshot(lightMachine, initialState, { + const [nextState] = transition(lightMachine, initialState, { type: 'NOTHING' }); @@ -288,7 +274,7 @@ describe('deterministic machine', () => { it('should work with substate nodes that have the same key', () => { const init = getInitialSnapshot(machine, undefined); - expect(getNextSnapshot(machine, init, { type: 'NEXT' }).value).toEqual( + expect(transition(machine, init, { type: 'NEXT' })[0].value).toEqual( 'test' ); }); @@ -296,7 +282,7 @@ describe('deterministic machine', () => { describe('forbidden events', () => { it('undefined transitions should forbid events', () => { - const walkState = getNextSnapshot( + const [walkState] = transition( lightMachine, lightMachine.resolveState({ value: { red: 'walk' } }), { type: 'TIMER' } diff --git a/packages/core/test/guards.test.ts b/packages/core/test/guards.test.ts index 1fdadf4e5f..099b6a6063 100644 --- a/packages/core/test/guards.test.ts +++ b/packages/core/test/guards.test.ts @@ -1010,6 +1010,48 @@ describe('guards - other', () => { expect(service.getSnapshot().value).toBe('c'); }); + + it('inline function guard should not leak into provided guards object', async () => { + const guards = {}; + + const machine = createMachine( + { + on: { + FOO: { + guard: () => false, + actions: () => {} + } + } + }, + { guards } + ); + + const actorRef = createActor(machine).start(); + actorRef.send({ type: 'FOO' }); + + expect(guards).toEqual({}); + }); + + it('inline builtin guard should not leak into provided guards object', async () => { + const guards = {}; + + const machine = createMachine( + { + on: { + FOO: { + guard: not(() => false), + actions: () => {} + } + } + }, + { guards } + ); + + const actorRef = createActor(machine).start(); + actorRef.send({ type: 'FOO' }); + + expect(guards).toEqual({}); + }); }); describe('not() guard', () => { diff --git a/packages/core/test/inspect.test.ts b/packages/core/test/inspect.test.ts index f199f9d7df..87ec10e6eb 100644 --- a/packages/core/test/inspect.test.ts +++ b/packages/core/test/inspect.test.ts @@ -697,64 +697,90 @@ describe('inspect', () => { }).start(); expect(simplifyEvents(events)).toMatchInlineSnapshot(` - [ - { - "actorId": "x:5", - "type": "@xstate.actor", - }, - { - "event": { - "type": "to_b", - }, - "transitions": [ - { - "eventType": "to_b", - "target": [ - "(machine).b", - ], - }, - ], - "type": "@xstate.microstep", - "value": "b", - }, - { - "event": { - "type": "to_c", - }, - "transitions": [ - { - "eventType": "to_c", - "target": [ - "(machine).c", - ], - }, - ], - "type": "@xstate.microstep", - "value": "c", - }, - { - "event": { - "input": undefined, - "type": "xstate.init", - }, - "sourceId": undefined, - "targetId": "x:5", - "type": "@xstate.event", - }, - { - "actorId": "x:5", - "event": { - "input": undefined, - "type": "xstate.init", - }, - "snapshot": { - "value": "c", - }, - "status": "active", - "type": "@xstate.snapshot", - }, - ] - `); +[ + { + "actorId": "x:5", + "type": "@xstate.actor", + }, + { + "event": { + "type": "to_b", + }, + "transitions": [ + { + "eventType": "to_b", + "target": [ + "(machine).b", + ], + }, + ], + "type": "@xstate.microstep", + "value": "b", + }, + { + "event": { + "type": "to_c", + }, + "transitions": [ + { + "eventType": "to_c", + "target": [ + "(machine).c", + ], + }, + ], + "type": "@xstate.microstep", + "value": "c", + }, + { + "event": { + "input": undefined, + "type": "xstate.init", + }, + "sourceId": undefined, + "targetId": "x:5", + "type": "@xstate.event", + }, + { + "action": { + "params": { + "delay": undefined, + "event": { + "type": "to_b", + }, + "id": undefined, + }, + "type": "xstate.raise", + }, + "type": "@xstate.action", + }, + { + "action": { + "params": { + "delay": undefined, + "event": { + "type": "to_c", + }, + "id": undefined, + }, + "type": "xstate.raise", + }, + "type": "@xstate.action", + }, + { + "actorId": "x:5", + "event": { + "input": undefined, + "type": "xstate.init", + }, + "snapshot": { + "value": "c", + }, + "status": "active", + "type": "@xstate.snapshot", + }, +] +`); }); it('should inspect microsteps for normal transitions', () => { @@ -979,46 +1005,46 @@ describe('inspect', () => { expect(simplifyEvents(events, (ev) => ev.type === '@xstate.action')) .toMatchInlineSnapshot(` - [ - { - "action": { - "params": undefined, - "type": "enter1", - }, - "type": "@xstate.action", - }, - { - "action": { - "params": undefined, - "type": "stringAction", - }, - "type": "@xstate.action", - }, - { - "action": { - "params": { - "foo": "bar", - }, - "type": "namedAction", - }, - "type": "@xstate.action", - }, - { - "action": { - "params": undefined, - "type": "(anonymous)", - }, - "type": "@xstate.action", - }, - { - "action": { - "params": undefined, - "type": "exit1", - }, - "type": "@xstate.action", - }, - ] - `); +[ + { + "action": { + "params": undefined, + "type": "enter1", + }, + "type": "@xstate.action", + }, + { + "action": { + "params": undefined, + "type": "stringAction", + }, + "type": "@xstate.action", + }, + { + "action": { + "params": { + "foo": "bar", + }, + "type": "namedAction", + }, + "type": "@xstate.action", + }, + { + "action": { + "params": undefined, + "type": "(anonymous)", + }, + "type": "@xstate.action", + }, + { + "action": { + "params": undefined, + "type": "exit1", + }, + "type": "@xstate.action", + }, +] +`); }); it('@xstate.microstep inspection events should report no transitions if an unknown event was sent', () => { diff --git a/packages/core/test/interpreter.test.ts b/packages/core/test/interpreter.test.ts index eacb255e31..4f2d62ddd9 100644 --- a/packages/core/test/interpreter.test.ts +++ b/packages/core/test/interpreter.test.ts @@ -1316,7 +1316,9 @@ describe('interpreter', () => { expect(typeof intervalService.subscribe === 'function').toBeTruthy(); intervalService.subscribe( - (state) => (count = state.context.count), + (state) => { + count = state.context.count; + }, undefined, () => { expect(count).toEqual(5); diff --git a/packages/core/test/scxml.test.ts b/packages/core/test/scxml.test.ts index a939ce35b4..6393799714 100644 --- a/packages/core/test/scxml.test.ts +++ b/packages/core/test/scxml.test.ts @@ -437,7 +437,7 @@ async function runTestToCompletion( describe('scxml', () => { const onlyTests: string[] = [ // e.g., 'test399.txml' - // 'test175.txml' + // 'test208.txml' ]; const testGroupKeys = Object.keys(testGroups); diff --git a/packages/core/test/transition.test.ts b/packages/core/test/transition.test.ts new file mode 100644 index 0000000000..dfbf13ff00 --- /dev/null +++ b/packages/core/test/transition.test.ts @@ -0,0 +1,573 @@ +import { sleep } from '@xstate-repo/jest-utils'; +import { + assign, + cancel, + createActor, + createMachine, + emit, + enqueueActions, + EventFrom, + ExecutableActionsFrom, + ExecutableSpawnAction, + fromPromise, + fromTransition, + log, + raise, + sendTo, + setup, + toPromise, + transition, + waitFor +} from '../src'; +import { createDoneActorEvent } from '../src/eventUtils'; +import { initialTransition } from '../src/transition'; +import assert from 'node:assert'; +import { resolveReferencedActor } from '../src/utils'; + +describe('transition function', () => { + it('should capture actions', () => { + const actionWithParams = jest.fn(); + const actionWithDynamicParams = jest.fn(); + const stringAction = jest.fn(); + + const machine = setup({ + types: { + context: {} as { count: number }, + events: {} as { type: 'event'; msg: string } + }, + actions: { + actionWithParams, + actionWithDynamicParams: (_, params: { msg: string }) => { + actionWithDynamicParams(params); + }, + stringAction + } + }).createMachine({ + entry: [ + { type: 'actionWithParams', params: { a: 1 } }, + 'stringAction', + assign({ count: 100 }) + ], + context: { count: 0 }, + on: { + event: { + actions: { + type: 'actionWithDynamicParams', + params: ({ event }) => { + return { msg: event.msg }; + } + } + } + } + }); + + const [state0, actions0] = initialTransition(machine); + + expect(state0.context.count).toBe(100); + expect(actions0).toEqual([ + expect.objectContaining({ type: 'actionWithParams', params: { a: 1 } }), + expect.objectContaining({ type: 'stringAction' }) + ]); + + expect(actionWithParams).not.toHaveBeenCalled(); + expect(stringAction).not.toHaveBeenCalled(); + + const [state1, actions1] = transition(machine, state0, { + type: 'event', + msg: 'hello' + }); + + expect(state1.context.count).toBe(100); + expect(actions1).toEqual([ + expect.objectContaining({ + type: 'actionWithDynamicParams', + params: { msg: 'hello' } + }) + ]); + + expect(actionWithDynamicParams).not.toHaveBeenCalled(); + }); + + it('should not execute a referenced serialized action', () => { + const foo = jest.fn(); + + const machine = setup({ + actions: { + foo + } + }).createMachine({ + entry: 'foo', + context: { count: 0 } + }); + + const [, actions] = initialTransition(machine); + + expect(foo).not.toHaveBeenCalled(); + }); + + it('should capture enqueued actions', () => { + const machine = createMachine({ + entry: [ + enqueueActions((x) => { + x.enqueue('stringAction'); + x.enqueue({ type: 'objectAction' }); + }) + ] + }); + + const [_state, actions] = initialTransition(machine); + + expect(actions).toEqual([ + expect.objectContaining({ type: 'stringAction' }), + expect.objectContaining({ type: 'objectAction' }) + ]); + }); + + it('delayed raise actions should be returned', async () => { + const machine = createMachine({ + initial: 'a', + states: { + a: { + entry: raise({ type: 'NEXT' }, { delay: 10 }), + on: { + NEXT: 'b' + } + }, + b: {} + } + }); + + const [state, actions] = initialTransition(machine); + + expect(state.value).toEqual('a'); + + expect(actions[0]).toEqual( + expect.objectContaining({ + type: 'xstate.raise', + params: expect.objectContaining({ + delay: 10, + event: { type: 'NEXT' } + }) + }) + ); + }); + + it('raise actions related to delayed transitions should be returned', async () => { + const machine = createMachine({ + initial: 'a', + states: { + a: { + after: { 10: 'b' } + }, + b: {} + } + }); + + const [state, actions] = initialTransition(machine); + + expect(state.value).toEqual('a'); + + expect(actions[0]).toEqual( + expect.objectContaining({ + type: 'xstate.raise', + params: expect.objectContaining({ + delay: 10, + event: { type: 'xstate.after.10.(machine).a' } + }) + }) + ); + }); + + it('cancel action should be returned', async () => { + const machine = createMachine({ + initial: 'a', + states: { + a: { + entry: raise({ type: 'NEXT' }, { delay: 10, id: 'myRaise' }), + on: { + NEXT: { + target: 'b', + actions: cancel('myRaise') + } + } + }, + b: {} + } + }); + + const [state] = initialTransition(machine); + + expect(state.value).toEqual('a'); + + const [, actions] = transition(machine, state, { type: 'NEXT' }); + + expect(actions).toContainEqual( + expect.objectContaining({ + type: 'xstate.cancel', + params: expect.objectContaining({ + sendId: 'myRaise' + }) + }) + ); + }); + + it('sendTo action should be returned', async () => { + const machine = createMachine({ + initial: 'a', + invoke: { + src: createMachine({}), + id: 'someActor' + }, + states: { + a: { + on: { + NEXT: { + actions: sendTo('someActor', { type: 'someEvent' }) + } + } + } + } + }); + + const [state, actions0] = initialTransition(machine); + + expect(state.value).toEqual('a'); + + expect(actions0).toContainEqual( + expect.objectContaining({ + type: 'xstate.spawnChild', + params: expect.objectContaining({ + id: 'someActor' + }) + }) + ); + + const [, actions] = transition(machine, state, { type: 'NEXT' }); + + expect(actions).toContainEqual( + expect.objectContaining({ + type: 'xstate.sendTo', + params: expect.objectContaining({ + targetId: 'someActor' + }) + }) + ); + }); + + it('emit actions should be returned', async () => { + const machine = createMachine({ + initial: 'a', + context: { count: 10 }, + states: { + a: { + on: { + NEXT: { + actions: emit(({ context }) => ({ + type: 'counted', + count: context.count + })) + } + } + } + } + }); + + const [state] = initialTransition(machine); + + expect(state.value).toEqual('a'); + + const [, nextActions] = transition(machine, state, { type: 'NEXT' }); + + expect(nextActions).toContainEqual( + expect.objectContaining({ + type: 'xstate.emit', + params: expect.objectContaining({ + event: { type: 'counted', count: 10 } + }) + }) + ); + }); + + it('log actions should be returned', async () => { + const machine = createMachine({ + initial: 'a', + context: { count: 10 }, + states: { + a: { + on: { + NEXT: { + actions: log(({ context }) => `count: ${context.count}`) + } + } + } + } + }); + + const [state] = initialTransition(machine); + + expect(state.value).toEqual('a'); + + const [, nextActions] = transition(machine, state, { type: 'NEXT' }); + + expect(nextActions).toContainEqual( + expect.objectContaining({ + type: 'xstate.log', + params: expect.objectContaining({ + value: 'count: 10' + }) + }) + ); + }); + + it('should calculate the next snapshot for transition logic', () => { + const logic = fromTransition( + (state, event) => { + if (event.type === 'next') { + return { count: state.count + 1 }; + } else { + return state; + } + }, + { count: 0 } + ); + + const [init] = initialTransition(logic); + const [s1] = transition(logic, init, { type: 'next' }); + expect(s1.context.count).toEqual(1); + const [s2] = transition(logic, s1, { type: 'next' }); + expect(s2.context.count).toEqual(2); + }); + + it('should calculate the next snapshot for machine logic', () => { + const machine = createMachine({ + initial: 'a', + states: { + a: { + on: { + NEXT: 'b' + } + }, + b: { + on: { + NEXT: 'c' + } + }, + c: {} + } + }); + + const [init] = initialTransition(machine); + const [s1] = transition(machine, init, { type: 'NEXT' }); + + expect(s1.value).toEqual('b'); + + const [s2] = transition(machine, s1, { type: 'NEXT' }); + + expect(s2.value).toEqual('c'); + }); + + it('should not execute entry actions', () => { + const fn = jest.fn(); + + const machine = createMachine({ + initial: 'a', + entry: fn, + states: { + a: {}, + b: {} + } + }); + + initialTransition(machine); + + expect(fn).not.toHaveBeenCalled(); + }); + + it('should not execute transition actions', () => { + const fn = jest.fn(); + + const machine = createMachine({ + initial: 'a', + states: { + a: { + on: { + event: { + target: 'b', + actions: fn + } + } + }, + b: {} + } + }); + + const [init] = initialTransition(machine); + const [nextSnapshot] = transition(machine, init, { type: 'event' }); + + expect(fn).not.toHaveBeenCalled(); + expect(nextSnapshot.value).toEqual('b'); + }); + + it('delayed events example (experimental)', async () => { + const db = { + state: undefined as any + }; + + const machine = createMachine({ + initial: 'start', + states: { + start: { + on: { + next: 'waiting' + } + }, + waiting: { + after: { + 10: 'done' + } + }, + done: { + type: 'final' + } + } + }); + + async function execute(action: ExecutableActionsFrom) { + if (action.type === 'xstate.raise' && action.params.delay) { + const currentTime = Date.now(); + const startedAt = currentTime; + const elapsed = currentTime - startedAt; + const timeRemaining = Math.max(0, action.params.delay - elapsed); + + await new Promise((res) => setTimeout(res, timeRemaining)); + postEvent(action.params.event); + } + } + + // POST /workflow + async function postStart() { + const [state, actions] = initialTransition(machine); + + db.state = JSON.stringify(state); + + // execute actions + for (const action of actions) { + await execute(action); + } + } + + // POST /workflow/{sessionId} + async function postEvent(event: EventFrom) { + const [nextState, actions] = transition( + machine, + machine.resolveState(JSON.parse(db.state)), + event + ); + + db.state = JSON.stringify(nextState); + + for (const action of actions) { + await execute(action); + } + } + + await postStart(); + postEvent({ type: 'next' }); + + await sleep(15); + expect(JSON.parse(db.state).status).toBe('done'); + }); + + it('serverless workflow example (experimental)', async () => { + const db = { + state: undefined as any + }; + + const machine = setup({ + actors: { + sendWelcomeEmail: fromPromise(async () => { + calls.push('sendWelcomeEmail'); + return { + status: 'sent' + }; + }) + } + }).createMachine({ + initial: 'sendingWelcomeEmail', + states: { + sendingWelcomeEmail: { + invoke: { + src: 'sendWelcomeEmail', + input: () => ({ message: 'hello world', subject: 'hi' }), + onDone: 'logSent' + } + }, + logSent: { + invoke: { + src: fromPromise(async () => {}), + onDone: 'finish' + } + }, + finish: {} + } + }); + + const calls: string[] = []; + + async function execute(action: ExecutableActionsFrom) { + switch (action.type) { + case 'xstate.spawnChild': { + const spawnAction = action as ExecutableSpawnAction; + const logic = + typeof spawnAction.params.src === 'string' + ? resolveReferencedActor(machine, spawnAction.params.src) + : spawnAction.params.src; + assert('transition' in logic); + const output = await toPromise( + createActor(logic, spawnAction.params).start() + ); + postEvent(createDoneActorEvent(spawnAction.params.id, output)); + } + default: + break; + } + } + + // POST /workflow + async function postStart() { + const [state, actions] = initialTransition(machine); + + db.state = JSON.stringify(state); + + // execute actions + for (const action of actions) { + await execute(action); + } + } + + // POST /workflow/{sessionId} + async function postEvent(event: EventFrom) { + const [nextState, actions] = transition( + machine, + machine.resolveState(JSON.parse(db.state)), + event + ); + + db.state = JSON.stringify(nextState); + + // "sync" built-in actions: assign, raise, cancel, stop + // "external" built-in actions: sendTo, raise w/delay, log + for (const action of actions) { + await execute(action); + } + } + + await postStart(); + postEvent({ type: 'sent' }); + + expect(calls).toEqual(['sendWelcomeEmail']); + + await sleep(10); + expect(JSON.parse(db.state).value).toBe('finish'); + }); +}); diff --git a/packages/xstate-graph/src/TestModel.ts b/packages/xstate-graph/src/TestModel.ts index 7d5cba504a..ac6d70219e 100644 --- a/packages/xstate-graph/src/TestModel.ts +++ b/packages/xstate-graph/src/TestModel.ts @@ -455,7 +455,7 @@ export function createTestModel( }, events: (state) => { const events = - typeof getEvents === 'function' ? getEvents(state) : getEvents ?? []; + typeof getEvents === 'function' ? getEvents(state) : (getEvents ?? []); return __unsafe_getAllOwnEventDescriptors(state).flatMap( (eventType: string) => { diff --git a/packages/xstate-graph/src/actorScope.ts b/packages/xstate-graph/src/actorScope.ts index a1783a3654..0168a2c498 100644 --- a/packages/xstate-graph/src/actorScope.ts +++ b/packages/xstate-graph/src/actorScope.ts @@ -10,6 +10,7 @@ export function createMockActorScope(): AnyActorScope { defer: () => {}, system: emptyActor.system, // TODO: mock system? stopChild: () => {}, - emit: () => {} + emit: () => {}, + actionExecutor: () => {} }; }