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/packages/core/src/actions/spawnChild.ts b/packages/core/src/actions/spawnChild.ts index d2b7eaf78e..bbf285830c 100644 --- a/packages/core/src/actions/spawnChild.ts +++ b/packages/core/src/actions/spawnChild.ts @@ -172,17 +172,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, 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/setup.ts b/packages/core/src/setup.ts index 8f18d398a6..6a0ee5dcbd 100644 --- a/packages/core/src/setup.ts +++ b/packages/core/src/setup.ts @@ -75,7 +75,7 @@ export function setup< TEvent extends AnyEventObject, // TODO: consider using a stricter `EventObject` here TActors extends Record = {}, TChildrenMap extends Record = {}, - TActions extends Record< + TActionParams extends Record< string, ParameterizedObject['params'] | undefined > = {}, @@ -115,13 +115,13 @@ export function setup< : never; }; actions?: { - [K in keyof TActions]: ActionFunction< + [K in keyof TActionParams]: ActionFunction< TContext, + TEvent, // Expression event TEvent, - TEvent, - TActions[K], + TActionParams[K], ToProvidedActor, - ToParameterizedObject, + ToParameterizedObject, ToParameterizedObject, TDelay, TEmitted @@ -139,7 +139,7 @@ export function setup< [K in TDelay]: DelayConfig< TContext, TEvent, - ToParameterizedObject['params'], + ToParameterizedObject['params'], TEvent >; }; @@ -151,7 +151,7 @@ export function setup< TContext, TEvent, ToProvidedActor, - ToParameterizedObject, + ToParameterizedObject, ToParameterizedObject, TDelay, TTag, @@ -170,7 +170,7 @@ export function setup< Record >, ToProvidedActor, - ToParameterizedObject, + ToParameterizedObject, ToParameterizedObject, TDelay, ToStateValue, diff --git a/packages/core/src/stateUtils.ts b/packages/core/src/stateUtils.ts index 659abbf831..e911b2798b 100644 --- a/packages/core/src/stateUtils.ts +++ b/packages/core/src/stateUtils.ts @@ -1,7 +1,7 @@ import isDevelopment from '#is-development'; import { MachineSnapshot, cloneMachineSnapshot } from './State.ts'; import type { StateNode } from './StateNode.ts'; -import { raise } from './actions.ts'; +import { assign, raise, sendTo } from './actions.ts'; import { createAfterEvent, createDoneStateEvent } from './eventUtils.ts'; import { cancel } from './actions/cancel.ts'; import { spawnChild } from './actions/spawnChild.ts'; @@ -37,7 +37,9 @@ import { ActionFunction, AnyTransitionConfig, ProvidedActor, - AnyActorScope + AnyActorScope, + ActionFunctionEnqueuer, + AnyActorRef } from './types.ts'; import { resolveOutput, @@ -1550,17 +1552,63 @@ function resolveAndExecuteActionsWithContext( params: actionParams } }); + const actions: UnknownAction[] = []; + const enq: ActionFunctionEnqueuer< + any, + any, + any, + any, + any, + any, + any, + any + > = { + assign: (...args) => { + actions.push(assign(...args) as UnknownAction); + }, + action: (actionFn) => { + actions.push(actionFn); + }, + raise: (...args) => { + actions.push(raise(...args) as UnknownAction); + }, + sendTo: (...args) => { + actions.push(sendTo(...args) as UnknownAction); + }, + check: (guard) => + evaluateGuard( + guard, + intermediateSnapshot.context, + event, + intermediateSnapshot + ) + }; try { executingCustomAction = resolvedAction; - resolvedAction(actionArgs, actionParams); + resolvedAction(actionArgs, actionParams, enq); } finally { executingCustomAction = false; } + + if (actions.length) { + intermediateSnapshot = resolveAndExecuteActionsWithContext( + intermediateSnapshot, + event, + actorScope, + actions, + extra, + retries + ); + + return intermediateSnapshot; + } } if (!('resolve' in resolvedAction)) { if (actorScope.self._processingStatus === ProcessingStatus.Running) { executeAction(); + } else if (resolvedAction.length === 3) { + executeAction(); } else { actorScope.defer(() => { executeAction(); 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/types.ts b/packages/core/src/types.ts index 624d75c194..b0e88895e2 100644 --- a/packages/core/src/types.ts +++ b/packages/core/src/types.ts @@ -1,13 +1,14 @@ import type { MachineSnapshot } from './State.ts'; import type { StateMachine } from './StateMachine.ts'; import type { StateNode } from './StateNode.ts'; -import { AssignArgs } from './actions/assign.ts'; +import { AssignArgs, assign } from './actions/assign.ts'; import { PromiseActorLogic } from './actors/promise.ts'; import { Guard, GuardPredicate, UnknownGuard } from './guards.ts'; import type { Actor, ProcessingStatus } from './createActor.ts'; import { Spawner } from './spawn.ts'; import { AnyActorSystem, Clock } from './system.js'; import { InspectionEvent } from './inspection.ts'; +import type { raise, sendTo } from './actions.ts'; export type Identity = { [K in keyof T]: T[K] }; @@ -169,6 +170,57 @@ export type OutputFrom = ? (TSnapshot & { status: 'done' })['output'] : never; +export interface ActionFunctionEnqueuer< + TContext extends MachineContext, + TExpressionEvent extends EventObject, + TEvent extends EventObject, + TActor extends ProvidedActor, + TAction extends ParameterizedObject, + TGuard extends ParameterizedObject, + TDelay extends string, + TEmitted extends EventObject +> { + assign: ( + ...args: Parameters< + typeof assign + > + ) => void; + raise: ( + ...args: Parameters< + typeof raise< + TContext, + TExpressionEvent, + TEvent, + undefined, + TDelay, + TDelay + > + > + ) => void; + action: ( + actionFn: + | NoRequiredParams + | WithDynamicParams + | ((args: ActionArgs) => void) + ) => void; + check: ( + guard: Guard + ) => boolean; + sendTo: ( + ...args: Parameters< + typeof sendTo< + TContext, + TExpressionEvent, + undefined, + TTargetActor, + TEvent, + TDelay, + TDelay + > + > + ) => void; +} + export type ActionFunction< TContext extends MachineContext, TExpressionEvent extends EventObject, @@ -180,7 +232,20 @@ export type ActionFunction< TDelay extends string, TEmitted extends EventObject > = { - (args: ActionArgs, params: TParams): void; + ( + args: ActionArgs, + params: TParams, + enqueue: ActionFunctionEnqueuer< + TContext, + TExpressionEvent, + TEvent, + TActor, + TAction, + TGuard, + TDelay, + TEmitted + > + ): void; _out_TEvent?: TEvent; // TODO: it feels like we should be able to remove this since now `TEvent` is "observable" by `self` _out_TActor?: TActor; _out_TAction?: TAction; diff --git a/packages/core/test/actions.test.ts b/packages/core/test/actions.test.ts index 389404bfeb..05bc22074b 100644 --- a/packages/core/test/actions.test.ts +++ b/packages/core/test/actions.test.ts @@ -1734,7 +1734,7 @@ describe('entry/exit actions', () => { interpreter.stop(); }); - it('should note execute referenced custom actions correctly when stopping an interpreter', () => { + it('should not execute referenced custom actions correctly when stopping an interpreter', () => { const spy = jest.fn(); const parent = createMachine( { @@ -2749,6 +2749,23 @@ describe('enqueueActions', () => { expect(snapshot.context).toEqual({ count: 42 }); }); + it('should execute assigns when resolving the initial snapshot (inline actions)', () => { + const machine = createMachine({ + context: { + count: 0 + }, + entry: (_, _params, enq) => { + enq.assign({ + count: 42 + }); + } + }); + + const snapshot = createActor(machine).getSnapshot(); + + expect(snapshot.context).toEqual({ count: 42 }); + }); + it('should be able to check a simple referenced guard', () => { const spy = jest.fn().mockImplementation(() => true); const machine = createMachine( @@ -2929,776 +2946,2233 @@ describe('enqueueActions', () => { }); }); -describe('sendParent', () => { - // https://github.com/statelyai/xstate/issues/711 - it('TS: should compile for any event', () => { - interface ChildEvent { - type: 'CHILD'; - } +describe('with inline actions', () => { + it('should execute a simple referenced action', () => { + const spy = jest.fn(); - const child = createMachine({ - types: {} as { - events: ChildEvent; + const machine = createMachine( + { + // entry: enqueueActions(({ enqueue }) => { + // enqueue('someAction'); + // }) + entry: (_, __, enq) => { + enq.action('someAction'); + } }, - id: 'child', - initial: 'start', - states: { - start: { - // This should not be a TypeScript error - entry: [sendParent({ type: 'PARENT' })] + { + actions: { + someAction: spy } } - }); + ); - expect(child).toBeTruthy(); + createActor(machine).start(); + + expect(spy).toHaveBeenCalledTimes(1); }); -}); -describe('sendTo', () => { - it('should be able to send an event to an actor', (done) => { - const childMachine = createMachine({ - types: {} as { - events: { type: 'EVENT' }; + it('should execute multiple different referenced actions', () => { + const spy1 = jest.fn(); + const spy2 = jest.fn(); + + const machine = createMachine( + { + // entry: enqueueActions(({ enqueue }) => { + // enqueue('someAction'); + // enqueue('otherAction'); + // }) + entry: (_, _params, enq) => { + enq.action('someAction'); + enq.action('otherAction'); + } }, - initial: 'waiting', - states: { - waiting: { - on: { - EVENT: { - actions: () => done() - } - } + { + actions: { + someAction: spy1, + otherAction: spy2 } } - }); + ); - const parentMachine = createMachine({ - types: {} as { - context: { - child: ActorRefFromLogic; - }; - }, - context: ({ spawn }) => ({ - child: spawn(childMachine) - }), - entry: sendTo(({ context }) => context.child, { type: 'EVENT' }) - }); + createActor(machine).start(); - createActor(parentMachine).start(); + expect(spy1).toHaveBeenCalledTimes(1); + expect(spy2).toHaveBeenCalledTimes(1); }); - it('should be able to send an event from expression to an actor', (done) => { - const childMachine = createMachine({ - types: {} as { - events: { type: 'EVENT'; count: number }; + it('should execute multiple same referenced actions', () => { + const spy = jest.fn(); + + const machine = createMachine( + { + entry: (_, _params, enq) => { + enq.action('someAction'); + enq.action('someAction'); + } }, - initial: 'waiting', - states: { - waiting: { - on: { - EVENT: { - actions: () => done() - } - } + { + actions: { + someAction: spy } } - }); + ); - const parentMachine = createMachine({ - types: {} as { - context: { - child: ActorRefFromLogic; - count: number; - }; - }, - context: ({ spawn }) => { - return { - child: spawn(childMachine, { id: 'child' }), - count: 42 - }; - }, - entry: sendTo( - ({ context }) => context.child, - ({ context }) => ({ type: 'EVENT', count: context.count }) - ) - }); + createActor(machine).start(); - createActor(parentMachine).start(); + expect(spy).toHaveBeenCalledTimes(2); }); - it('should report a type error for an invalid event', () => { - const childMachine = createMachine({ - types: {} as { - events: { type: 'EVENT' }; + it('should execute a parameterized action', () => { + const spy = jest.fn(); + + const machine = createMachine( + { + // entry: enqueueActions(({ enqueue }) => { + // enqueue({ + // type: 'someAction', + // params: { answer: 42 } + // }); + // }), + entry: (_, _params, enq) => { + enq.action({ + type: 'someAction', + params: { answer: 42 } + }); + } }, - initial: 'waiting', - states: { - waiting: { - on: { - EVENT: {} - } + { + actions: { + someAction: (_, params) => spy(params) } } - }); + ); - createMachine({ - types: {} as { - context: { - child: ActorRefFromLogic; - }; - }, - context: ({ spawn }) => ({ - child: spawn(childMachine) - }), - entry: sendTo(({ context }) => context.child, { - // @ts-expect-error - type: 'UNKNOWN' - }) - }); + createActor(machine).start(); + + expect(spy).toMatchMockCallsInlineSnapshot(` + [ + [ + { + "answer": 42, + }, + ], + ] + `); }); - it('should be able to send an event to a named actor', (done) => { - const childMachine = createMachine({ - types: {} as { - events: { type: 'EVENT' }; - }, - initial: 'waiting', - states: { - waiting: { - on: { - EVENT: { - actions: () => done() - } - } - } + it('should execute a function', () => { + const spy = jest.fn(); + + const machine = createMachine({ + entry: (_, _params, enq) => { + enq.action(spy); } }); - const parentMachine = createMachine({ - types: {} as { - context: { child: ActorRefFromLogic }; - }, - context: ({ spawn }) => ({ - child: spawn(childMachine, { id: 'child' }) - }), - // No type-safety for the event yet - entry: sendTo('child', { type: 'EVENT' }) - }); + createActor(machine).start(); - createActor(parentMachine).start(); + expect(spy).toHaveBeenCalledTimes(1); }); - it('should be able to send an event directly to an ActorRef', (done) => { - const childMachine = createMachine({ - types: {} as { - events: { type: 'EVENT' }; - }, - initial: 'waiting', - states: { - waiting: { - on: { - EVENT: { - actions: () => done() - } + it('should execute a builtin action using its own action creator', () => { + const spy = jest.fn(); + + const machine = createMachine({ + on: { + FOO: { + // actions: enqueueActions(({ enqueue }) => { + // enqueue( + // raise({ + // type: 'RAISED' + // }) + // ); + // }) + actions: (_, _params, enq) => { + enq.raise({ + type: 'RAISED' + }); } + }, + RAISED: { + actions: spy } } }); - const parentMachine = createMachine({ - types: {} as { - context: { child: ActorRefFromLogic }; - }, - context: ({ spawn }) => ({ - child: spawn(childMachine) - }), - entry: sendTo(({ context }) => context.child, { type: 'EVENT' }) - }); + const actorRef = createActor(machine).start(); - createActor(parentMachine).start(); + actorRef.send({ type: 'FOO' }); + + expect(spy).toHaveBeenCalledTimes(1); }); - it('should be able to read from event', () => { - expect.assertions(1); + it('should execute a builtin action using its bound action creator', () => { + const spy = jest.fn(); + const machine = createMachine({ - types: {} as { - context: Record>; - events: { type: 'EVENT'; value: string }; - }, - initial: 'a', - context: ({ spawn }) => ({ - foo: spawn( - fromCallback(({ receive }) => { - receive((event) => { - expect(event).toEqual({ type: 'EVENT' }); + on: { + FOO: { + // actions: enqueueActions(({ enqueue }) => { + // enqueue.raise({ + // type: 'RAISED' + // }); + // }) + actions: (_, _params, enq) => { + enq.raise({ + type: 'RAISED' }); - }) - ) - }), - states: { - a: { - on: { - EVENT: { - actions: sendTo(({ context, event }) => context[event.value], { - type: 'EVENT' - }) - } } + }, + RAISED: { + actions: spy } } }); - const service = createActor(machine).start(); + const actorRef = createActor(machine).start(); - service.send({ type: 'EVENT', value: 'foo' }); + actorRef.send({ type: 'FOO' }); + + expect(spy).toHaveBeenCalledTimes(1); }); - it('should error if given a string', () => { + it('should execute assigns when resolving the initial snapshot', () => { const machine = createMachine({ - invoke: { - id: 'child', - src: fromCallback(() => {}) + context: { + count: 0 }, - entry: sendTo('child', 'a string') + entry: (_, __, enq) => { + enq.assign({ + count: 42 + }); + } }); - const errorSpy = jest.fn(); - - const actorRef = createActor(machine); - actorRef.subscribe({ - error: errorSpy - }); - actorRef.start(); + const snapshot = createActor(machine).getSnapshot(); - expect(errorSpy).toMatchMockCallsInlineSnapshot(` - [ - [ - [Error: Only event objects may be used with sendTo; use sendTo({ type: "a string" }) instead], - ], - ] - `); + expect(snapshot.context).toEqual({ count: 42 }); }); -}); -describe('raise', () => { - it('should be able to send a delayed event to itself', (done) => { - const machine = createMachine({ - initial: 'a', - states: { - a: { - entry: raise( - { type: 'EVENT' }, - { - delay: 1 - } - ), - on: { - TO_B: 'b' - } - }, - b: { - on: { - EVENT: 'c' - } + it('should be able to check a simple referenced guard', () => { + const spy = jest.fn().mockImplementation(() => true); + const machine = createMachine( + { + context: { + count: 0 }, - c: { - type: 'final' + // entry: enqueueActions(({ check }) => { + // check('alwaysTrue'); + // }) + entry: (_, __, enq) => { + enq.check('alwaysTrue'); + } + }, + { + guards: { + alwaysTrue: spy } } - }); - - const service = createActor(machine).start(); + ); - service.subscribe({ complete: () => done() }); + createActor(machine); - // Ensures that the delayed self-event is sent when in the `b` state - service.send({ type: 'TO_B' }); + expect(spy).toHaveBeenCalledTimes(1); }); - it('should be able to send a delayed event to itself with delay = 0', (done) => { - const machine = createMachine({ - initial: 'a', - states: { - a: { - entry: raise( - { type: 'EVENT' }, - { - delay: 0 + it('should be able to check a parameterized guard', () => { + const spy = jest.fn(); + + const machine = createMachine( + { + context: { + count: 0 + }, + // entry: enqueueActions(({ check }) => { + // check({ + // type: 'alwaysTrue', + // params: { + // max: 100 + // } + // }); + // }) + entry: (_, __, enq) => { + enq.check({ + type: 'alwaysTrue', + params: { + max: 100 } - ), - on: { - EVENT: 'b' + }); + } + }, + { + guards: { + alwaysTrue: (_, params) => { + spy(params); + return true; } - }, - b: {} + } } - }); - - const service = createActor(machine).start(); + ); - // The state should not be changed yet; `delay: 0` is equivalent to `setTimeout(..., 0)` - expect(service.getSnapshot().value).toEqual('a'); + createActor(machine); - setTimeout(() => { - // The state should be changed now - expect(service.getSnapshot().value).toEqual('b'); - done(); - }); + expect(spy).toMatchMockCallsInlineSnapshot(` + [ + [ + { + "max": 100, + }, + ], + ] + `); }); - it('should be able to raise an event and respond to it in the same state', () => { + it('should provide self', () => { + expect.assertions(1); const machine = createMachine({ - initial: 'a', - states: { - a: { - entry: raise({ type: 'TO_B' }), - on: { - TO_B: 'b' - } - }, - b: { - type: 'final' - } + // entry: enqueueActions(({ self }) => { + // expect(self.send).toBeDefined(); + // }) + entry: ({ self }) => { + expect(self.send).toBeDefined(); } }); - const service = createActor(machine).start(); - - expect(service.getSnapshot().value).toEqual('b'); + createActor(machine).start(); }); - it('should be able to raise a delayed event and respond to it in the same state', (done) => { - const machine = createMachine({ - initial: 'a', - states: { - a: { - entry: raise( - { type: 'TO_B' }, - { - delay: 100 - } - ), - on: { - TO_B: 'b' + it('should be able to communicate with the parent using params', () => { + type ParentEvent = { type: 'FOO' }; + + const childMachine = setup({ + types: {} as { + input: { + parent?: ActorRef, ParentEvent>; + }; + context: { + parent?: ActorRef, ParentEvent>; + }; + }, + actions: { + // mySendParent: enqueueActions( + // ({ context, enqueue }, event: ParentEvent) => { + // if (!context.parent) { + // // it's here just for illustration purposes + // console.log( + // 'WARN: an attempt to send an event to a non-existent parent' + // ); + // return; + // } + // enqueue.sendTo(context.parent, event); + // } + // ) + mySendParent: ({ context }, params: ParentEvent, enq) => { + if (!context.parent) { + // it's here just for illustration purposes + console.log( + 'WARN: an attempt to send an event to a non-existent parent' + ); + return; } - }, - b: { - type: 'final' + enq.sendTo(context.parent, params); + } + } + }).createMachine({ + context: ({ input }) => ({ parent: input.parent }), + entry: { + type: 'mySendParent', + params: { + type: 'FOO' } } }); - const service = createActor(machine).start(); - - service.subscribe({ complete: () => done() }); - - setTimeout(() => { - // didn't transition yet - expect(service.getSnapshot().value).toEqual('a'); - }, 50); - }); + const spy = jest.fn(); - it('should accept event expression', () => { - const machine = createMachine({ - initial: 'a', - states: { - a: { - on: { - NEXT: { - actions: raise(() => ({ type: 'RAISED' })) - }, - RAISED: 'b' - } - }, - b: {} + const parentMachine = setup({ + types: {} as { events: ParentEvent }, + actors: { + child: childMachine + } + }).createMachine({ + on: { + FOO: { + actions: spy + } + }, + invoke: { + src: 'child', + input: ({ self }) => ({ parent: self }) } }); - const actor = createActor(machine).start(); - - actor.send({ type: 'NEXT' }); + createActor(parentMachine).start(); - expect(actor.getSnapshot().value).toBe('b'); + expect(spy).toHaveBeenCalledTimes(1); }); +}); - it('should be possible to access context in the event expression', () => { +describe('sendParent', () => { + // https://github.com/statelyai/xstate/issues/711 + it('TS: should compile for any event', () => { + interface ChildEvent { + type: 'CHILD'; + } + + const child = createMachine({ + types: {} as { + events: ChildEvent; + }, + id: 'child', + initial: 'start', + states: { + start: { + // This should not be a TypeScript error + entry: [sendParent({ type: 'PARENT' })] + } + } + }); + + expect(child).toBeTruthy(); + }); +}); + +describe('sendTo', () => { + it('should be able to send an event to an actor', (done) => { + const childMachine = createMachine({ + types: {} as { + events: { type: 'EVENT' }; + }, + initial: 'waiting', + states: { + waiting: { + on: { + EVENT: { + actions: () => done() + } + } + } + } + }); + + const parentMachine = createMachine({ + types: {} as { + context: { + child: ActorRefFromLogic; + }; + }, + context: ({ spawn }) => ({ + child: spawn(childMachine) + }), + entry: sendTo(({ context }) => context.child, { type: 'EVENT' }) + }); + + createActor(parentMachine).start(); + }); + + it('should be able to send an event from expression to an actor', (done) => { + const childMachine = createMachine({ + types: {} as { + events: { type: 'EVENT'; count: number }; + }, + initial: 'waiting', + states: { + waiting: { + on: { + EVENT: { + actions: () => done() + } + } + } + } + }); + + const parentMachine = createMachine({ + types: {} as { + context: { + child: ActorRefFromLogic; + count: number; + }; + }, + context: ({ spawn }) => { + return { + child: spawn(childMachine, { id: 'child' }), + count: 42 + }; + }, + entry: sendTo( + ({ context }) => context.child, + ({ context }) => ({ type: 'EVENT', count: context.count }) + ) + }); + + createActor(parentMachine).start(); + }); + + it('should report a type error for an invalid event', () => { + const childMachine = createMachine({ + types: {} as { + events: { type: 'EVENT' }; + }, + initial: 'waiting', + states: { + waiting: { + on: { + EVENT: {} + } + } + } + }); + + createMachine({ + types: {} as { + context: { + child: ActorRefFromLogic; + }; + }, + context: ({ spawn }) => ({ + child: spawn(childMachine) + }), + entry: sendTo(({ context }) => context.child, { + // @ts-expect-error + type: 'UNKNOWN' + }) + }); + }); + + it('should be able to send an event to a named actor', (done) => { + const childMachine = createMachine({ + types: {} as { + events: { type: 'EVENT' }; + }, + initial: 'waiting', + states: { + waiting: { + on: { + EVENT: { + actions: () => done() + } + } + } + } + }); + + const parentMachine = createMachine({ + types: {} as { + context: { child: ActorRefFromLogic }; + }, + context: ({ spawn }) => ({ + child: spawn(childMachine, { id: 'child' }) + }), + // No type-safety for the event yet + entry: sendTo('child', { type: 'EVENT' }) + }); + + createActor(parentMachine).start(); + }); + + it('should be able to send an event directly to an ActorRef', (done) => { + const childMachine = createMachine({ + types: {} as { + events: { type: 'EVENT' }; + }, + initial: 'waiting', + states: { + waiting: { + on: { + EVENT: { + actions: () => done() + } + } + } + } + }); + + const parentMachine = createMachine({ + types: {} as { + context: { child: ActorRefFromLogic }; + }, + context: ({ spawn }) => ({ + child: spawn(childMachine) + }), + entry: sendTo(({ context }) => context.child, { type: 'EVENT' }) + }); + + createActor(parentMachine).start(); + }); + + it('should be able to read from event', () => { + expect.assertions(1); + const machine = createMachine({ + types: {} as { + context: Record>; + events: { type: 'EVENT'; value: string }; + }, + initial: 'a', + context: ({ spawn }) => ({ + foo: spawn( + fromCallback(({ receive }) => { + receive((event) => { + expect(event).toEqual({ type: 'EVENT' }); + }); + }) + ) + }), + states: { + a: { + on: { + EVENT: { + actions: sendTo(({ context, event }) => context[event.value], { + type: 'EVENT' + }) + } + } + } + } + }); + + const service = createActor(machine).start(); + + service.send({ type: 'EVENT', value: 'foo' }); + }); + + it('should error if given a string', () => { + const machine = createMachine({ + invoke: { + id: 'child', + src: fromCallback(() => {}) + }, + entry: sendTo('child', 'a string') + }); + + const errorSpy = jest.fn(); + + const actorRef = createActor(machine); + actorRef.subscribe({ + error: errorSpy + }); + actorRef.start(); + + expect(errorSpy).toMatchMockCallsInlineSnapshot(` + [ + [ + [Error: Only event objects may be used with sendTo; use sendTo({ type: "a string" }) instead], + ], + ] + `); + }); +}); + +describe('sendTo with inline actions', () => { + it('should be able to send an event to an actor', (done) => { + const childMachine = createMachine({ + types: {} as { + events: { type: 'EVENT' }; + }, + initial: 'waiting', + states: { + waiting: { + on: { + EVENT: { + actions: () => done() + } + } + } + } + }); + + const parentMachine = createMachine({ + types: {} as { + context: { + child: ActorRefFromLogic; + }; + }, + context: ({ spawn }) => ({ + child: spawn(childMachine) + }), + // entry: sendTo(({ context }) => context.child, { type: 'EVENT' }) + entry: (_, _params, enq) => { + enq.sendTo(({ context }) => context.child, { type: 'EVENT' }); + } + }); + + createActor(parentMachine).start(); + }); + + it('should be able to send an event from expression to an actor', (done) => { + const childMachine = createMachine({ + types: {} as { + events: { type: 'EVENT'; count: number }; + }, + initial: 'waiting', + states: { + waiting: { + on: { + EVENT: { + actions: () => done() + } + } + } + } + }); + + const parentMachine = createMachine({ + types: {} as { + context: { + child: ActorRefFromLogic; + count: number; + }; + }, + context: ({ spawn }) => { + return { + child: spawn(childMachine, { id: 'child' }), + count: 42 + }; + }, + // entry: sendTo( + // ({ context }) => context.child, + // ({ context }) => ({ type: 'EVENT', count: context.count }) + // ) + entry: (_, _params, enq) => { + enq.sendTo( + ({ context }) => context.child, + ({ context }) => ({ type: 'EVENT', count: context.count }) + ); + } + }); + + createActor(parentMachine).start(); + }); + + it('should report a type error for an invalid event', () => { + const childMachine = createMachine({ + types: {} as { + events: { type: 'EVENT' }; + }, + initial: 'waiting', + states: { + waiting: { + on: { + EVENT: {} + } + } + } + }); + + createMachine({ + types: {} as { + context: { + child: ActorRefFromLogic; + }; + }, + context: ({ spawn }) => ({ + child: spawn(childMachine) + }), + // entry: sendTo(({ context }) => context.child, { + // // @ts-expect-error + // type: 'UNKNOWN' + // }) + entry: (_, _params, enq) => { + enq.sendTo(({ context }) => context.child, { + // @ts-expect-error + type: 'UNKNOWN' + }); + } + }); + }); + + it('should be able to send an event to a named actor', (done) => { + const childMachine = createMachine({ + types: {} as { + events: { type: 'EVENT' }; + }, + initial: 'waiting', + states: { + waiting: { + on: { + EVENT: { + actions: () => done() + } + } + } + } + }); + + const parentMachine = createMachine({ + types: {} as { + context: { child: ActorRefFromLogic }; + }, + context: ({ spawn }) => ({ + child: spawn(childMachine, { id: 'child' }) + }), + // No type-safety for the event yet + entry: (_, _params, enq) => { + enq.sendTo('child', { type: 'EVENT' }); + } + }); + + createActor(parentMachine).start(); + }); + + it('should be able to send an event directly to an ActorRef', (done) => { + const childMachine = createMachine({ + types: {} as { + events: { type: 'EVENT' }; + }, + initial: 'waiting', + states: { + waiting: { + on: { + EVENT: { + actions: () => done() + } + } + } + } + }); + + const parentMachine = createMachine({ + types: {} as { + context: { child: ActorRefFromLogic }; + }, + context: ({ spawn }) => ({ + child: spawn(childMachine) + }), + entry: (_, _params, enq) => { + enq.sendTo(({ context }) => context.child, { type: 'EVENT' }); + } + }); + + createActor(parentMachine).start(); + }); + + it('should be able to read from event', () => { + expect.assertions(1); + const machine = createMachine({ + types: {} as { + context: Record>; + events: { type: 'EVENT'; value: string }; + }, + initial: 'a', + context: ({ spawn }) => ({ + foo: spawn( + fromCallback(({ receive }) => { + receive((event) => { + expect(event).toEqual({ type: 'EVENT' }); + }); + }) + ) + }), + states: { + a: { + on: { + EVENT: { + actions: (_, _params, enq) => { + enq.sendTo(({ context, event }) => context[event.value], { + type: 'EVENT' + }); + } + } + } + } + } + }); + + const service = createActor(machine).start(); + + service.send({ type: 'EVENT', value: 'foo' }); + }); + + it('should error if given a string', () => { + const machine = createMachine({ + invoke: { + id: 'child', + src: fromCallback(() => {}) + }, + entry: (_, _params, enq) => { + enq.sendTo('child', 'a string'); + } + }); + + const errorSpy = jest.fn(); + + const actorRef = createActor(machine); + actorRef.subscribe({ + error: errorSpy + }); + actorRef.start(); + + expect(errorSpy).toMatchMockCallsInlineSnapshot(` + [ + [ + [Error: Only event objects may be used with sendTo; use sendTo({ type: "a string" }) instead], + ], + ] + `); + }); +}); + +describe('raise', () => { + it('should be able to send a delayed event to itself', (done) => { + const machine = createMachine({ + initial: 'a', + states: { + a: { + entry: raise( + { type: 'EVENT' }, + { + delay: 1 + } + ), + on: { + TO_B: 'b' + } + }, + b: { + on: { + EVENT: 'c' + } + }, + c: { + type: 'final' + } + } + }); + + const service = createActor(machine).start(); + + service.subscribe({ complete: () => done() }); + + // Ensures that the delayed self-event is sent when in the `b` state + service.send({ type: 'TO_B' }); + }); + + it('should be able to send a delayed event to itself with delay = 0', (done) => { + const machine = createMachine({ + initial: 'a', + states: { + a: { + entry: raise( + { type: 'EVENT' }, + { + delay: 0 + } + ), + on: { + EVENT: 'b' + } + }, + b: {} + } + }); + + const service = createActor(machine).start(); + + // 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(); + }); + }); + + it('should be able to raise an event and respond to it in the same state', () => { + const machine = createMachine({ + initial: 'a', + states: { + a: { + entry: raise({ type: 'TO_B' }), + on: { + TO_B: 'b' + } + }, + b: { + type: 'final' + } + } + }); + + const service = createActor(machine).start(); + + expect(service.getSnapshot().value).toEqual('b'); + }); + + it('should be able to raise a delayed event and respond to it in the same state', (done) => { + const machine = createMachine({ + initial: 'a', + states: { + a: { + entry: raise( + { type: 'TO_B' }, + { + delay: 100 + } + ), + on: { + TO_B: 'b' + } + }, + b: { + type: 'final' + } + } + }); + + const service = createActor(machine).start(); + + service.subscribe({ complete: () => done() }); + + setTimeout(() => { + // didn't transition yet + expect(service.getSnapshot().value).toEqual('a'); + }, 50); + }); + + it('should accept event expression', () => { + const machine = createMachine({ + initial: 'a', + states: { + a: { + on: { + NEXT: { + actions: raise(() => ({ type: 'RAISED' })) + }, + RAISED: 'b' + } + }, + b: {} + } + }); + + const actor = createActor(machine).start(); + + actor.send({ type: 'NEXT' }); + + expect(actor.getSnapshot().value).toBe('b'); + }); + + it('should be possible to access context in the event expression', () => { + type MachineEvent = + | { + type: 'RAISED'; + } + | { + type: 'NEXT'; + }; + interface MachineContext { + eventType: MachineEvent['type']; + } + const machine = createMachine({ + types: {} as { context: MachineContext; events: MachineEvent }, + initial: 'a', + context: { + eventType: 'RAISED' + }, + states: { + a: { + on: { + NEXT: { + actions: raise(({ context }) => ({ + type: context.eventType + })) + }, + RAISED: 'b' + } + }, + b: {} + } + }); + + const actor = createActor(machine).start(); + + actor.send({ type: 'NEXT' }); + + expect(actor.getSnapshot().value).toBe('b'); + }); + + it('should error if given a string', () => { + const machine = createMachine({ + entry: raise( + // @ts-ignore + 'a string' + ) + }); + + const errorSpy = jest.fn(); + + const actorRef = createActor(machine); + actorRef.subscribe({ + error: errorSpy + }); + actorRef.start(); + + expect(errorSpy).toMatchMockCallsInlineSnapshot(` + [ + [ + [Error: Only event objects may be used with raise; use raise({ type: "a string" }) instead], + ], + ] + `); + }); +}); + +describe('raise with inline actions', () => { + it('should be able to send a delayed event to itself', (done) => { + const machine = createMachine({ + initial: 'a', + states: { + a: { + // entry: raise( + // { type: 'EVENT' }, + // { + // delay: 1 + // } + // ), + entry: (_, _params, enq) => { + enq.raise({ type: 'EVENT' }, { delay: 1 }); + }, + on: { + TO_B: 'b' + } + }, + b: { + on: { + EVENT: 'c' + } + }, + c: { + type: 'final' + } + } + }); + + const service = createActor(machine).start(); + + service.subscribe({ complete: () => done() }); + + // Ensures that the delayed self-event is sent when in the `b` state + service.send({ type: 'TO_B' }); + }); + + it('should be able to send a delayed event to itself with delay = 0', (done) => { + const machine = createMachine({ + initial: 'a', + states: { + a: { + // entry: raise( + // { type: 'EVENT' }, + // { + // delay: 0 + // } + // ), + entry: (_, _params, enq) => { + enq.raise({ type: 'EVENT' }, { delay: 0 }); + }, + on: { + EVENT: 'b' + } + }, + b: {} + } + }); + + const service = createActor(machine).start(); + + // 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(); + }); + }); + + it('should be able to raise an event and respond to it in the same state', () => { + const machine = createMachine({ + initial: 'a', + states: { + a: { + // entry: raise({ type: 'TO_B' }), + entry: (_, _params, enq) => { + enq.raise({ type: 'TO_B' }); + }, + on: { + TO_B: 'b' + } + }, + b: { + type: 'final' + } + } + }); + + const service = createActor(machine).start(); + + expect(service.getSnapshot().value).toEqual('b'); + }); + + it('should be able to raise a delayed event and respond to it in the same state', (done) => { + const machine = createMachine({ + initial: 'a', + states: { + a: { + // entry: raise( + // { type: 'TO_B' }, + // { + // delay: 100 + // } + // ), + entry: (_, _params, enq) => { + enq.raise( + { type: 'TO_B' }, + { + delay: 100 + } + ); + }, + on: { + TO_B: 'b' + } + }, + b: { + type: 'final' + } + } + }); + + const service = createActor(machine).start(); + + service.subscribe({ complete: () => done() }); + + setTimeout(() => { + // didn't transition yet + expect(service.getSnapshot().value).toEqual('a'); + }, 50); + }); + + it('should accept event expression', () => { + const machine = createMachine({ + initial: 'a', + states: { + a: { + on: { + NEXT: { + // actions: raise(() => ({ type: 'RAISED' })) + actions: (_, _params, enq) => { + enq.raise(() => ({ type: 'RAISED' })); + } + }, + RAISED: 'b' + } + }, + b: {} + } + }); + + const actor = createActor(machine).start(); + + actor.send({ type: 'NEXT' }); + + expect(actor.getSnapshot().value).toBe('b'); + }); + + it('should be possible to access context in the event expression', () => { type MachineEvent = | { type: 'RAISED'; } - | { - type: 'NEXT'; - }; - interface MachineContext { - eventType: MachineEvent['type']; - } - const machine = createMachine({ - types: {} as { context: MachineContext; events: MachineEvent }, - initial: 'a', - context: { - eventType: 'RAISED' - }, - states: { - a: { - on: { - NEXT: { - actions: raise(({ context }) => ({ - type: context.eventType - })) - }, - RAISED: 'b' - } - }, - b: {} + | { + type: 'NEXT'; + }; + interface MachineContext { + eventType: MachineEvent['type']; + } + const machine = createMachine({ + types: {} as { context: MachineContext; events: MachineEvent }, + initial: 'a', + context: { + eventType: 'RAISED' + }, + states: { + a: { + on: { + NEXT: { + // actions: raise(({ context }) => ({ + // type: context.eventType + // })) + actions: (_, _params, enq) => { + enq.raise(({ context }) => ({ + type: context.eventType + })); + } + }, + RAISED: 'b' + } + }, + b: {} + } + }); + + const actor = createActor(machine).start(); + + actor.send({ type: 'NEXT' }); + + expect(actor.getSnapshot().value).toBe('b'); + }); + + it('should error if given a string', () => { + const machine = createMachine({ + // entry: raise( + // // @ts-ignore + // 'a string' + // ) + entry: (_, _params, enq) => { + enq.raise( + // @ts-expect-error + 'a string' + ); + } + }); + + const errorSpy = jest.fn(); + + const actorRef = createActor(machine); + actorRef.subscribe({ + error: errorSpy + }); + actorRef.start(); + + expect(errorSpy).toMatchMockCallsInlineSnapshot(` + [ + [ + [Error: Only event objects may be used with raise; use raise({ type: "a string" }) instead], + ], + ] + `); + }); +}); + +describe('cancel', () => { + it('should be possible to cancel a raised delayed event', async () => { + const machine = createMachine({ + initial: 'a', + states: { + a: { + on: { + NEXT: { + actions: raise({ type: 'RAISED' }, { delay: 1, id: 'myId' }) + }, + RAISED: 'b', + CANCEL: { + actions: cancel('myId') + } + } + }, + b: {} + } + }); + + const actor = createActor(machine).start(); + + // This should raise the 'RAISED' event after 1ms + actor.send({ type: 'NEXT' }); + + // This should cancel the 'RAISED' event + actor.send({ type: 'CANCEL' }); + + await new Promise((res) => { + setTimeout(() => { + expect(actor.getSnapshot().value).toBe('a'); + res(); + }, 10); + }); + }); + + it('should cancel only the delayed event in the machine that scheduled it when canceling the event with the same ID in the machine that sent it first', async () => { + const fooSpy = jest.fn(); + const barSpy = jest.fn(); + + const machine = createMachine({ + invoke: [ + { + id: 'foo', + src: createMachine({ + id: 'foo', + entry: raise({ type: 'event' }, { id: 'sameId', delay: 100 }), + on: { + event: { actions: fooSpy }, + cancel: { actions: cancel('sameId') } + } + }) + }, + { + id: 'bar', + src: createMachine({ + id: 'bar', + entry: raise({ type: 'event' }, { id: 'sameId', delay: 100 }), + on: { + event: { actions: barSpy } + } + }) + } + ], + on: { + cancelFoo: { + actions: sendTo('foo', { type: 'cancel' }) + } + } + }); + const actor = createActor(machine).start(); + + await sleep(50); + + // This will cause the foo actor to cancel its 'sameId' delayed event + // This should NOT cancel the 'sameId' delayed event in the other actor + actor.send({ type: 'cancelFoo' }); + + await sleep(55); + + expect(fooSpy).not.toHaveBeenCalled(); + expect(barSpy).toHaveBeenCalledTimes(1); + }); + + it('should cancel only the delayed event in the machine that scheduled it when canceling the event with the same ID in the machine that sent it second', async () => { + const fooSpy = jest.fn(); + const barSpy = jest.fn(); + + const machine = createMachine({ + invoke: [ + { + id: 'foo', + src: createMachine({ + id: 'foo', + entry: raise({ type: 'event' }, { id: 'sameId', delay: 100 }), + on: { + event: { actions: fooSpy } + } + }) + }, + { + id: 'bar', + src: createMachine({ + id: 'bar', + entry: raise({ type: 'event' }, { id: 'sameId', delay: 100 }), + on: { + event: { actions: barSpy }, + cancel: { actions: cancel('sameId') } + } + }) + } + ], + on: { + cancelBar: { + actions: sendTo('bar', { type: 'cancel' }) + } + } + }); + const actor = createActor(machine).start(); + + await sleep(50); + + // This will cause the bar actor to cancel its 'sameId' delayed event + // This should NOT cancel the 'sameId' delayed event in the other actor + actor.send({ type: 'cancelBar' }); + + await sleep(55); + + expect(fooSpy).toHaveBeenCalledTimes(1); + expect(barSpy).not.toHaveBeenCalled(); + }); + + it('should not try to clear an undefined timeout when canceling an unscheduled timer', async () => { + const spy = jest.fn(); + + const machine = createMachine({ + on: { + FOO: { + actions: cancel('foo') + } + } + }); + + const actorRef = createActor(machine, { + clock: { + setTimeout, + clearTimeout: spy + } + }).start(); + + actorRef.send({ + type: 'FOO' + }); + + expect(spy.mock.calls.length).toBe(0); + }); +}); + +describe('assign action order', () => { + it('should preserve action order', () => { + const captured: number[] = []; + + const machine = createMachine({ + types: {} as { + context: { count: number }; + }, + context: { count: 0 }, + entry: [ + ({ context }) => captured.push(context.count), // 0 + assign({ count: ({ context }) => context.count + 1 }), + ({ context }) => captured.push(context.count), // 1 + assign({ count: ({ context }) => context.count + 1 }), + ({ context }) => captured.push(context.count) // 2 + ] + }); + + createActor(machine).start(); + + expect(captured).toEqual([0, 1, 2]); + }); + + it('should deeply preserve action order', () => { + const captured: number[] = []; + + interface CountCtx { + count: number; + } + + const machine = createMachine( + { + types: {} as { + context: CountCtx; + }, + context: { count: 0 }, + entry: [ + ({ context }) => captured.push(context.count), // 0 + enqueueActions(({ enqueue }) => { + enqueue(assign({ count: ({ context }) => context.count + 1 })); + enqueue({ type: 'capture' }); + enqueue(assign({ count: ({ context }) => context.count + 1 })); + }), + ({ context }) => captured.push(context.count) // 2 + ] + }, + { + actions: { + capture: ({ context }) => captured.push(context.count) + } + } + ); + + createActor(machine).start(); + + expect(captured).toEqual([0, 1, 2]); + }); + + it('should deeply preserve action order (inline actions)', () => { + const captured: number[] = []; + + interface CountCtx { + count: number; + } + + const machine = createMachine( + { + types: {} as { + context: CountCtx; + }, + context: { count: 0 }, + entry: [ + ({ context }) => captured.push(context.count), // 0 + (_, _params, enq) => { + enq.assign({ count: ({ context }) => context.count + 1 }); + enq.action({ type: 'capture' }); + enq.assign({ count: ({ context }) => context.count + 1 }); + }, + ({ context }) => captured.push(context.count) // 2 + ] + }, + { + actions: { + capture: ({ context }) => captured.push(context.count) + } + } + ); + + createActor(machine).start(); + + expect(captured).toEqual([0, 1, 2]); + }); + + it('should capture correct context values on subsequent transitions', () => { + let captured: number[] = []; + + const machine = createMachine({ + types: {} as { + context: { counter: number }; + }, + context: { + counter: 0 + }, + on: { + EV: { + actions: [ + assign({ counter: ({ context }) => context.counter + 1 }), + ({ context }) => captured.push(context.counter) + ] + } + } + }); + + const service = createActor(machine).start(); + + service.send({ type: 'EV' }); + service.send({ type: 'EV' }); + + expect(captured).toEqual([1, 2]); + }); + + it('should capture correct context values on subsequent transitions (inline action)', () => { + let captured: number[] = []; + + const machine = createMachine({ + types: {} as { + context: { counter: number }; + }, + context: { + counter: 0 + }, + on: { + EV: { + actions: (_, _params, enq) => { + enq.assign({ + counter: ({ context }) => context.counter + 1 + }); + enq.action(({ context }) => captured.push(context.counter)); + } + } + } + }); + + const service = createActor(machine).start(); + + service.send({ type: 'EV' }); + service.send({ type: 'EV' }); + + expect(captured).toEqual([1, 2]); + }); +}); + +describe('assign action order with inline actions', () => { + it('should preserve action order', () => { + const captured: number[] = []; + + const machine = createMachine({ + types: {} as { + context: { count: number }; + }, + context: { count: 0 }, + entry: (_, _params, enq) => { + enq.action(({ context }) => captured.push(context.count)); // 0 + enq.assign({ count: ({ context }) => context.count + 1 }); + enq.action(({ context }) => captured.push(context.count)); // 1 + enq.assign({ count: ({ context }) => context.count + 1 }); + enq.action(({ context }) => captured.push(context.count)); // 2 + } + }); + + createActor(machine).start(); + + expect(captured).toEqual([0, 1, 2]); + }); + + it('should deeply preserve action order', () => { + const captured: number[] = []; + + interface CountCtx { + count: number; + } + + const machine = setup({ + types: { + context: {} as CountCtx + }, + actions: { + capture: ({ context }) => captured.push(context.count) + } + }).createMachine({ + context: { count: 0 }, + entry: [ + (_, _params, enq) => { + enq.action(({ context }) => captured.push(context.count)); // 0 + enq.assign({ count: ({ context }) => context.count + 1 }); + enq.action({ type: 'capture' }); + enq.assign({ count: ({ context }) => context.count + 1 }); + enq.action(({ context }) => captured.push(context.count)); // 2 + } + ] + }); + + createActor(machine).start(); + + expect(captured).toEqual([0, 1, 2]); + }); + + it('should capture correct context values on subsequent transitions', () => { + let captured: number[] = []; + + const machine = createMachine({ + types: {} as { + context: { counter: number }; + }, + context: { + counter: 0 + }, + on: { + EV: { + // actions: [ + // assign({ counter: ({ context }) => context.counter + 1 }), + // ({ context }) => captured.push(context.counter) + // ] + actions: (_, _params, enq) => { + enq.assign({ counter: ({ context }) => context.counter + 1 }); + enq.action(({ context }) => captured.push(context.counter)); + } + } + } + }); + + const service = createActor(machine).start(); + + service.send({ type: 'EV' }); + service.send({ type: 'EV' }); + + expect(captured).toEqual([1, 2]); + }); +}); + +describe('types', () => { + it('assign actions should be inferred correctly', () => { + createMachine({ + types: {} as { + context: { count: number; text: string }; + events: { type: 'inc'; value: number } | { type: 'say'; value: string }; + }, + context: { + count: 0, + text: 'hello' + }, + entry: [ + assign({ count: 31 }), + // @ts-expect-error + assign({ count: 'string' }), + + assign({ count: () => 31 }), + // @ts-expect-error + assign({ count: () => 'string' }), + + assign({ count: ({ context }) => context.count + 31 }), + // @ts-expect-error + assign({ count: ({ context }) => context.text + 31 }), + + assign(() => ({ count: 31 })), + // @ts-expect-error + assign(() => ({ count: 'string' })), + + assign(({ context }) => ({ count: context.count + 31 })), + // @ts-expect-error + assign(({ context }) => ({ count: context.text + 31 })) + ], + on: { + say: { + actions: [ + assign({ text: ({ event }) => event.value }), + // @ts-expect-error + assign({ count: ({ event }) => event.value }), + + assign(({ event }) => ({ text: event.value })), + // @ts-expect-error + assign(({ event }) => ({ count: event.value })) + ] + } } }); + }); - const actor = createActor(machine).start(); + it('assign actions should be inferred correctly (inline actions)', () => { + createMachine({ + types: {} as { + context: { count: number; text: string }; + events: { type: 'inc'; value: number } | { type: 'say'; value: string }; + }, + context: { + count: 0, + text: 'hello' + }, + entry: (_, _params, enq) => { + enq.assign({ count: 31 }); + // @ts-expect-error + enq.assign({ count: 'string' }); - actor.send({ type: 'NEXT' }); + enq.assign({ count: () => 31 }); + // @ts-expect-error + enq.assign({ count: () => 'string' }); - expect(actor.getSnapshot().value).toBe('b'); - }); + enq.assign({ count: ({ context }) => context.count + 31 }); + // @ts-expect-error + enq.assign({ count: ({ context }) => context.text + 31 }); - it('should error if given a string', () => { - const machine = createMachine({ - entry: raise( - // @ts-ignore - 'a string' - ) - }); + enq.assign(() => ({ count: 31 })); + // @ts-expect-error + enq.assign(() => ({ count: 'string' })); - const errorSpy = jest.fn(); + enq.assign(({ context }) => ({ count: context.count + 31 })); + // @ts-expect-error + enq.assign(({ context }) => ({ count: context.text + 31 })); + }, + on: { + say: { + actions: (_, _params, enq) => { + enq.assign({ text: ({ event }) => event.value }); + // @ts-expect-error + enq.assign({ count: ({ event }) => event.value }); - const actorRef = createActor(machine); - actorRef.subscribe({ - error: errorSpy + enq.assign(({ event }) => ({ text: event.value })); + // @ts-expect-error + enq.assign(({ event }) => ({ count: event.value })); + } + } + } }); - actorRef.start(); - - expect(errorSpy).toMatchMockCallsInlineSnapshot(` - [ - [ - [Error: Only event objects may be used with raise; use raise({ type: "a string" }) instead], - ], - ] - `); }); }); -describe('cancel', () => { - it('should be possible to cancel a raised delayed event', async () => { - const machine = createMachine({ - initial: 'a', - states: { - a: { - on: { - NEXT: { - actions: raise({ type: 'RAISED' }, { delay: 1, id: 'myId' }) - }, - RAISED: 'b', - CANCEL: { - actions: cancel('myId') - } +describe('types with inline actions', () => { + it('assign actions should be inferred correctly', () => { + createMachine({ + types: {} as { + context: { count: number; text: string }; + events: { type: 'inc'; value: number } | { type: 'say'; value: string }; + }, + context: { + count: 0, + text: 'hello' + }, + entry: (_, _params, enq) => { + enq.assign({ count: 31 }), + // @ts-expect-error + enq.assign({ count: 'string' }), + enq.assign({ count: () => 31 }), + // @ts-expect-error + enq.assign({ count: () => 'string' }), + enq.assign({ count: ({ context }) => context.count + 31 }), + // @ts-expect-error + enq.assign({ count: ({ context }) => context.text + 31 }), + enq.assign(() => ({ count: 31 })), + // @ts-expect-error + enq.assign(() => ({ count: 'string' })), + enq.assign(({ context }) => ({ count: context.count + 31 })), + // @ts-expect-error + enq.assign(({ context }) => ({ count: context.text + 31 })); + }, + on: { + say: { + // actions: [ + // assign({ text: ({ event }) => event.value }), + // // @ts-expect-error + // assign({ count: ({ event }) => event.value }), + + // assign(({ event }) => ({ text: event.value })), + // // @ts-expect-error + // assign(({ event }) => ({ count: event.value })) + // ] + actions: (_, _params, enq) => { + enq.assign({ text: ({ event }) => event.value }); + // @ts-expect-error + enq.assign({ count: ({ event }) => event.value }); + + enq.assign(({ event }) => ({ text: event.value })); + // @ts-expect-error + enq.assign(({ event }) => ({ count: event.value })); } - }, - b: {} + } } }); + }); +}); - const actor = createActor(machine).start(); - - // This should raise the 'RAISED' event after 1ms - actor.send({ type: 'NEXT' }); +describe('action meta', () => { + it.todo( + 'base action objects should have meta.action as the same base action object' + ); - // This should cancel the 'RAISED' event - actor.send({ type: 'CANCEL' }); + it('should provide self', () => { + expect.assertions(1); - await new Promise((res) => { - setTimeout(() => { - expect(actor.getSnapshot().value).toBe('a'); - res(); - }, 10); + const machine = createMachine({ + entry: ({ self }) => { + expect(self.send).toBeDefined(); + } }); + + createActor(machine).start(); }); +}); - it('should cancel only the delayed event in the machine that scheduled it when canceling the event with the same ID in the machine that sent it first', async () => { - const fooSpy = jest.fn(); - const barSpy = jest.fn(); +describe('actions', () => { + it('should call transition actions in document order for same-level parallel regions', () => { + const actual: string[] = []; const machine = createMachine({ - invoke: [ - { - id: 'foo', - src: createMachine({ - id: 'foo', - entry: raise({ type: 'event' }, { id: 'sameId', delay: 100 }), - on: { - event: { actions: fooSpy }, - cancel: { actions: cancel('sameId') } + type: 'parallel', + states: { + a: { + on: { + FOO: { + actions: () => actual.push('a') } - }) + } }, - { - id: 'bar', - src: createMachine({ - id: 'bar', - entry: raise({ type: 'event' }, { id: 'sameId', delay: 100 }), - on: { - event: { actions: barSpy } + b: { + on: { + FOO: { + actions: () => actual.push('b') } - }) - } - ], - on: { - cancelFoo: { - actions: sendTo('foo', { type: 'cancel' }) + } } } }); - const actor = createActor(machine).start(); - - await sleep(50); - - // This will cause the foo actor to cancel its 'sameId' delayed event - // This should NOT cancel the 'sameId' delayed event in the other actor - actor.send({ type: 'cancelFoo' }); - - await sleep(55); + const service = createActor(machine).start(); + service.send({ type: 'FOO' }); - expect(fooSpy).not.toHaveBeenCalled(); - expect(barSpy).toHaveBeenCalledTimes(1); + expect(actual).toEqual(['a', 'b']); }); - it('should cancel only the delayed event in the machine that scheduled it when canceling the event with the same ID in the machine that sent it second', async () => { - const fooSpy = jest.fn(); - const barSpy = jest.fn(); + it('should call transition actions in document order for states at different levels of parallel regions', () => { + const actual: string[] = []; const machine = createMachine({ - invoke: [ - { - id: 'foo', - src: createMachine({ - id: 'foo', - entry: raise({ type: 'event' }, { id: 'sameId', delay: 100 }), - on: { - event: { actions: fooSpy } + type: 'parallel', + states: { + a: { + initial: 'a1', + states: { + a1: { + on: { + FOO: { + actions: () => actual.push('a1') + } + } } - }) + } }, - { - id: 'bar', - src: createMachine({ - id: 'bar', - entry: raise({ type: 'event' }, { id: 'sameId', delay: 100 }), - on: { - event: { actions: barSpy }, - cancel: { actions: cancel('sameId') } + b: { + on: { + FOO: { + actions: () => actual.push('b') } - }) - } - ], - on: { - cancelBar: { - actions: sendTo('bar', { type: 'cancel' }) + } } } }); - const actor = createActor(machine).start(); - - await sleep(50); - - // This will cause the bar actor to cancel its 'sameId' delayed event - // This should NOT cancel the 'sameId' delayed event in the other actor - actor.send({ type: 'cancelBar' }); - - await sleep(55); + const service = createActor(machine).start(); + service.send({ type: 'FOO' }); - expect(fooSpy).toHaveBeenCalledTimes(1); - expect(barSpy).not.toHaveBeenCalled(); + expect(actual).toEqual(['a1', 'b']); }); - it('should not try to clear an undefined timeout when canceling an unscheduled timer', async () => { + it('should call an inline action responding to an initial raise with the raised event', () => { const spy = jest.fn(); const machine = createMachine({ + entry: raise({ type: 'HELLO' }), on: { - FOO: { - actions: cancel('foo') + HELLO: { + actions: ({ event }) => { + spy(event); + } } } }); - const actorRef = createActor(machine, { - clock: { - setTimeout, - clearTimeout: spy + createActor(machine).start(); + + expect(spy).toHaveBeenCalledWith({ type: 'HELLO' }); + }); + + it('should call a referenced action responding to an initial raise with the raised event', () => { + const spy = jest.fn(); + + const machine = createMachine( + { + entry: raise({ type: 'HELLO' }), + on: { + HELLO: { + actions: 'foo' + } + } + }, + { + actions: { + foo: ({ event }) => { + spy(event); + } + } } - }).start(); + ); - actorRef.send({ - type: 'FOO' - }); + createActor(machine).start(); - expect(spy.mock.calls.length).toBe(0); + expect(spy).toHaveBeenCalledWith({ type: 'HELLO' }); }); -}); -describe('assign action order', () => { - it('should preserve action order', () => { - const captured: number[] = []; + it('should call an inline action responding to an initial raise with updated (non-initial) context', () => { + const spy = jest.fn(); const machine = createMachine({ - types: {} as { - context: { count: number }; - }, context: { count: 0 }, - entry: [ - ({ context }) => captured.push(context.count), // 0 - assign({ count: ({ context }) => context.count + 1 }), - ({ context }) => captured.push(context.count), // 1 - assign({ count: ({ context }) => context.count + 1 }), - ({ context }) => captured.push(context.count) // 2 - ] + entry: [assign({ count: 42 }), raise({ type: 'HELLO' })], + on: { + HELLO: { + actions: ({ context }) => { + spy(context); + } + } + } }); createActor(machine).start(); - expect(captured).toEqual([0, 1, 2]); + expect(spy).toHaveBeenCalledWith({ count: 42 }); }); - it('should deeply preserve action order', () => { - const captured: number[] = []; - - interface CountCtx { - count: number; - } + it('should call a referenced action responding to an initial raise with updated (non-initial) context', () => { + const spy = jest.fn(); const machine = createMachine( { - types: {} as { - context: CountCtx; - }, context: { count: 0 }, - entry: [ - ({ context }) => captured.push(context.count), // 0 - enqueueActions(({ enqueue }) => { - enqueue(assign({ count: ({ context }) => context.count + 1 })); - enqueue({ type: 'capture' }); - enqueue(assign({ count: ({ context }) => context.count + 1 })); - }), - ({ context }) => captured.push(context.count) // 2 - ] + entry: [assign({ count: 42 }), raise({ type: 'HELLO' })], + on: { + HELLO: { + actions: 'foo' + } + } }, { actions: { - capture: ({ context }) => captured.push(context.count) + foo: ({ context }) => { + spy(context); + } } } ); createActor(machine).start(); - expect(captured).toEqual([0, 1, 2]); + expect(spy).toHaveBeenCalledWith({ count: 42 }); }); - it('should capture correct context values on subsequent transitions', () => { - let captured: number[] = []; + it('should call inline entry custom action with undefined parametrized action object', () => { + const spy = jest.fn(); + createActor( + createMachine({ + entry: (_, params) => { + spy(params); + } + }) + ).start(); - const machine = createMachine({ - types: {} as { - context: { counter: number }; - }, - context: { - counter: 0 - }, - on: { - EV: { - actions: [ - assign({ counter: ({ context }) => context.counter + 1 }), - ({ context }) => captured.push(context.counter) - ] + expect(spy).toHaveBeenCalledWith(undefined); + }); + + it('should call inline entry builtin action with undefined parametrized action object', () => { + const spy = jest.fn(); + createActor( + createMachine({ + entry: assign((_, params) => { + spy(params); + return {}; + }) + }) + ).start(); + + expect(spy).toHaveBeenCalledWith(undefined); + }); + + it('should call inline transition custom action with undefined parametrized action object', () => { + const spy = jest.fn(); + + const actorRef = createActor( + createMachine({ + on: { + FOO: { + actions: (_, params) => { + spy(params); + } + } } - } - }); + }) + ).start(); + actorRef.send({ type: 'FOO' }); - const service = createActor(machine).start(); + expect(spy).toHaveBeenCalledWith(undefined); + }); - service.send({ type: 'EV' }); - service.send({ type: 'EV' }); + it('should call inline transition builtin action with undefined parameters', () => { + const spy = jest.fn(); - expect(captured).toEqual([1, 2]); + const actorRef = createActor( + createMachine({ + on: { + FOO: { + actions: assign((_, params) => { + spy(params); + return {}; + }) + } + } + }) + ).start(); + actorRef.send({ type: 'FOO' }); + + expect(spy).toHaveBeenCalledWith(undefined); }); -}); -describe('types', () => { - it('assign actions should be inferred correctly', () => { - createMachine({ - types: {} as { - context: { count: number; text: string }; - events: { type: 'inc'; value: number } | { type: 'say'; value: string }; - }, - context: { - count: 0, - text: 'hello' - }, - entry: [ - assign({ count: 31 }), - // @ts-expect-error - assign({ count: 'string' }), + it('should call a referenced custom action with undefined params when it has no params and it is referenced using a string', () => { + const spy = jest.fn(); - assign({ count: () => 31 }), - // @ts-expect-error - assign({ count: () => 'string' }), + createActor( + createMachine( + { + entry: 'myAction' + }, + { + actions: { + myAction: (_, params) => { + spy(params); + } + } + } + ) + ).start(); - assign({ count: ({ context }) => context.count + 31 }), - // @ts-expect-error - assign({ count: ({ context }) => context.text + 31 }), + expect(spy).toHaveBeenCalledWith(undefined); + }); - assign(() => ({ count: 31 })), - // @ts-expect-error - assign(() => ({ count: 'string' })), + it('should call a referenced builtin action with undefined params when it has no params and it is referenced using a string', () => { + const spy = jest.fn(); - assign(({ context }) => ({ count: context.count + 31 })), - // @ts-expect-error - assign(({ context }) => ({ count: context.text + 31 })) - ], - on: { - say: { - actions: [ - assign({ text: ({ event }) => event.value }), - // @ts-expect-error - assign({ count: ({ event }) => event.value }), + createActor( + createMachine( + { + entry: 'myAction' + }, + { + actions: { + myAction: assign((_, params) => { + spy(params); + return {}; + }) + } + } + ) + ).start(); + + expect(spy).toHaveBeenCalledWith(undefined); + }); + + it('should call a referenced custom action with the provided parametrized action object', () => { + const spy = jest.fn(); - assign(({ event }) => ({ text: event.value })), - // @ts-expect-error - assign(({ event }) => ({ count: event.value })) - ] + createActor( + createMachine( + { + entry: { + type: 'myAction', + params: { + foo: 'bar' + } + } + }, + { + actions: { + myAction: (_, params) => { + spy(params); + } + } } - } + ) + ).start(); + + expect(spy).toHaveBeenCalledWith({ + foo: 'bar' }); }); -}); -describe('action meta', () => { - it.todo( - 'base action objects should have meta.action as the same base action object' - ); + it('should call a referenced builtin action with the provided parametrized action object', () => { + const spy = jest.fn(); - it('should provide self', () => { - expect.assertions(1); + createActor( + createMachine( + { + entry: { + type: 'myAction', + params: { + foo: 'bar' + } + } + }, + { + actions: { + myAction: assign((_, params) => { + spy(params); + return {}; + }) + } + } + ) + ).start(); + + expect(spy).toHaveBeenCalledWith({ + foo: 'bar' + }); + }); + it('should warn if called in custom action', () => { const machine = createMachine({ - entry: ({ self }) => { - expect(self.send).toBeDefined(); + entry: () => { + assign({}); + raise({ type: '' }); + sendTo('', { type: '' }); + emit({ type: '' }); } }); createActor(machine).start(); + + expect(console.warn).toMatchMockCallsInlineSnapshot(` +[ + [ + "Custom actions should not call \`assign()\` 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 \`raise()\` 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.", + ], +] +`); }); }); -describe('actions', () => { +describe('actions with inline actions', () => { it('should call transition actions in document order for same-level parallel regions', () => { const actual: string[] = []; @@ -3708,14 +5182,18 @@ describe('actions', () => { a: { on: { FOO: { - actions: () => actual.push('a') + actions: (_, _params, enq) => { + enq.action(() => actual.push('a')); + } } } }, b: { on: { FOO: { - actions: () => actual.push('b') + actions: (_, _params, enq) => { + enq.action(() => actual.push('b')); + } } } } @@ -3739,7 +5217,9 @@ describe('actions', () => { a1: { on: { FOO: { - actions: () => actual.push('a1') + actions: (_, _params, enq) => { + enq.action(() => actual.push('a1')); + } } } } @@ -3748,7 +5228,9 @@ describe('actions', () => { b: { on: { FOO: { - actions: () => actual.push('b') + actions: (_, _params, enq) => { + enq.action(() => actual.push('b')); + } } } } @@ -3764,11 +5246,13 @@ describe('actions', () => { const spy = jest.fn(); const machine = createMachine({ - entry: raise({ type: 'HELLO' }), + entry: (_, _params, enq) => { + enq.raise({ type: 'HELLO' }); + }, on: { HELLO: { - actions: ({ event }) => { - spy(event); + actions: ({ event }, _params, enq) => { + enq.action(() => spy(event)); } } } @@ -3782,23 +5266,22 @@ describe('actions', () => { it('should call a referenced action responding to an initial raise with the raised event', () => { const spy = jest.fn(); - const machine = createMachine( - { - entry: raise({ type: 'HELLO' }), - on: { - HELLO: { - actions: 'foo' - } + const machine = setup({ + actions: { + foo: ({ event }) => { + spy(event); } + } + }).createMachine({ + entry: (_, _params, enq) => { + enq.raise({ type: 'HELLO' }); }, - { - actions: { - foo: ({ event }) => { - spy(event); - } + on: { + HELLO: { + actions: 'foo' } } - ); + }); createActor(machine).start(); @@ -3810,11 +5293,15 @@ describe('actions', () => { const machine = createMachine({ context: { count: 0 }, - entry: [assign({ count: 42 }), raise({ type: 'HELLO' })], + // entry: [assign({ count: 42 }), raise({ type: 'HELLO' })], + entry: (_, _params, enq) => { + enq.assign({ count: 42 }); + enq.raise({ type: 'HELLO' }); + }, on: { HELLO: { - actions: ({ context }) => { - spy(context); + actions: ({ context }, _params, enq) => { + enq.action(() => spy(context)); } } } @@ -3828,24 +5315,25 @@ describe('actions', () => { it('should call a referenced action responding to an initial raise with updated (non-initial) context', () => { const spy = jest.fn(); - const machine = createMachine( - { - context: { count: 0 }, - entry: [assign({ count: 42 }), raise({ type: 'HELLO' })], - on: { - HELLO: { - actions: 'foo' - } + const machine = setup({ + actions: { + foo: ({ context }) => { + spy(context); } + } + }).createMachine({ + context: { count: 0 }, + // entry: [assign({ count: 42 }), raise({ type: 'HELLO' })], + entry: (_, _params, enq) => { + enq.assign({ count: 42 }); + enq.raise({ type: 'HELLO' }); }, - { - actions: { - foo: ({ context }) => { - spy(context); - } + on: { + HELLO: { + actions: 'foo' } } - ); + }); createActor(machine).start(); @@ -3869,10 +5357,12 @@ describe('actions', () => { const spy = jest.fn(); createActor( createMachine({ - entry: assign((_, params) => { - spy(params); - return {}; - }) + entry: (_, params, enq) => { + enq.assign(() => { + spy(params); + return {}; + }); + } }) ).start(); @@ -3905,10 +5395,12 @@ describe('actions', () => { createMachine({ on: { FOO: { - actions: assign((_, params) => { - spy(params); - return {}; - }) + actions: (_, params, enq) => { + enq.assign(() => { + spy(params); + return {}; + }); + } } } }) @@ -3922,18 +5414,15 @@ describe('actions', () => { const spy = jest.fn(); createActor( - createMachine( - { - entry: 'myAction' - }, - { - actions: { - myAction: (_, params) => { - spy(params); - } + setup({ + actions: { + myAction: (_, params) => { + spy(params); } } - ) + }).createMachine({ + entry: 'myAction' + }) ).start(); expect(spy).toHaveBeenCalledWith(undefined); @@ -3943,19 +5432,16 @@ describe('actions', () => { const spy = jest.fn(); createActor( - createMachine( - { - entry: 'myAction' - }, - { - actions: { - myAction: assign((_, params) => { - spy(params); - return {}; - }) - } + setup({ + actions: { + myAction: assign((_, params) => { + spy(params); + return {}; + }) } - ) + }).createMachine({ + entry: 'myAction' + }) ).start(); expect(spy).toHaveBeenCalledWith(undefined); @@ -3965,23 +5451,20 @@ describe('actions', () => { const spy = jest.fn(); createActor( - createMachine( - { - entry: { - type: 'myAction', - params: { - foo: 'bar' - } + setup({ + actions: { + myAction: (_, params) => { + spy(params); } - }, - { - actions: { - myAction: (_, params) => { - spy(params); - } + } + }).createMachine({ + entry: { + type: 'myAction', + params: { + foo: 'bar' } } - ) + }) ).start(); expect(spy).toHaveBeenCalledWith({ @@ -3993,58 +5476,27 @@ describe('actions', () => { const spy = jest.fn(); createActor( - createMachine( - { - entry: { - type: 'myAction', - params: { - foo: 'bar' - } - } - }, - { - actions: { - myAction: assign((_, params) => { + setup({ + actions: { + myAction: (_, params, enq) => { + enq.assign(() => { spy(params); return {}; - }) + }); } } - ) + }).createMachine({ + entry: { + type: 'myAction', + params: { + foo: 'bar' + } + } + }) ).start(); expect(spy).toHaveBeenCalledWith({ foo: 'bar' }); }); - - it('should warn if called in custom action', () => { - const machine = createMachine({ - entry: () => { - assign({}); - raise({ type: '' }); - sendTo('', { type: '' }); - emit({ type: '' }); - } - }); - - createActor(machine).start(); - - expect(console.warn).toMatchMockCallsInlineSnapshot(` -[ - [ - "Custom actions should not call \`assign()\` 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 \`raise()\` 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.", - ], -] -`); - }); }); diff --git a/packages/core/test/assign.test.ts b/packages/core/test/assign.test.ts index b0e8055fb4..63f90c6c24 100644 --- a/packages/core/test/assign.test.ts +++ b/packages/core/test/assign.test.ts @@ -2,7 +2,8 @@ import { assign, createActor, createMachine, - enqueueActions + enqueueActions, + setup } from '../src/index.ts'; interface CounterContext { @@ -285,6 +286,371 @@ describe('assign', () => { }); }); +describe('assign with inline actions', () => { + const counterMachine = createMachine({ + types: {} as { context: CounterContext }, + initial: 'counting', + context: { count: 0, foo: 'bar' }, + states: { + counting: { + on: { + INC: [ + { + target: 'counting', + actions: (_, _params, enq) => { + enq.assign(({ context }) => ({ + count: context.count + 1 + })); + } + } + ], + DEC: [ + { + target: 'counting', + // actions: [ + // assign({ + // count: ({ context }) => context.count - 1 + // }) + // ] + actions: (_, _params, enq) => { + enq.assign({ + count: ({ context }) => context.count - 1 + }); + } + } + ], + WIN_PROP: [ + { + target: 'counting', + // actions: [ + // assign({ + // count: () => 100, + // foo: () => 'win' + // }) + // ] + actions: (_, _params, enq) => { + enq.assign({ + count: () => 100, + foo: () => 'win' + }); + } + } + ], + WIN_STATIC: [ + { + target: 'counting', + // actions: [ + // assign({ + // count: 100, + // foo: 'win' + // }) + // ] + actions: (_, _params, enq) => { + enq.assign({ + count: 100, + foo: 'win' + }); + } + } + ], + WIN_MIX: [ + { + target: 'counting', + // actions: [ + // assign({ + // count: () => 100, + // foo: 'win' + // }) + // ] + actions: (_, _params, enq) => { + enq.assign({ + count: () => 100, + foo: 'win' + }); + } + } + ], + WIN: [ + { + target: 'counting', + // actions: [ + // assign(() => ({ + // count: 100, + // foo: 'win' + // })) + // ] + actions: (_, _params, enq) => { + enq.assign(() => ({ + count: 100, + foo: 'win' + })); + } + } + ], + SET_MAYBE: [ + { + // actions: [ + // assign({ + // maybe: 'defined' + // }) + // ] + actions: (_, _params, enq) => { + enq.assign({ + maybe: 'defined' + }); + } + } + ] + } + } + } + }); + + it('applies the assignment to the external state (property assignment)', () => { + const actorRef = createActor(counterMachine).start(); + actorRef.send({ + type: 'DEC' + }); + const oneState = actorRef.getSnapshot(); + + expect(oneState.value).toEqual('counting'); + expect(oneState.context).toEqual({ count: -1, foo: 'bar' }); + + actorRef.send({ type: 'DEC' }); + const twoState = actorRef.getSnapshot(); + + expect(twoState.value).toEqual('counting'); + expect(twoState.context).toEqual({ count: -2, foo: 'bar' }); + }); + + it('applies the assignment to the external state', () => { + const actorRef = createActor(counterMachine).start(); + actorRef.send({ + type: 'INC' + }); + const oneState = actorRef.getSnapshot(); + + expect(oneState.value).toEqual('counting'); + expect(oneState.context).toEqual({ count: 1, foo: 'bar' }); + + actorRef.send({ type: 'INC' }); + const twoState = actorRef.getSnapshot(); + + expect(twoState.value).toEqual('counting'); + expect(twoState.context).toEqual({ count: 2, foo: 'bar' }); + }); + + it('applies the assignment to multiple properties (property assignment)', () => { + const actorRef = createActor(counterMachine).start(); + actorRef.send({ + type: 'WIN_PROP' + }); + + expect(actorRef.getSnapshot().context).toEqual({ count: 100, foo: 'win' }); + }); + + it('applies the assignment to multiple properties (static)', () => { + const actorRef = createActor(counterMachine).start(); + actorRef.send({ + type: 'WIN_STATIC' + }); + + expect(actorRef.getSnapshot().context).toEqual({ count: 100, foo: 'win' }); + }); + + it('applies the assignment to multiple properties (static + prop assignment)', () => { + const actorRef = createActor(counterMachine).start(); + actorRef.send({ + type: 'WIN_MIX' + }); + + expect(actorRef.getSnapshot().context).toEqual({ count: 100, foo: 'win' }); + }); + + it('applies the assignment to multiple properties', () => { + const actorRef = createActor(counterMachine).start(); + actorRef.send({ + type: 'WIN' + }); + + expect(actorRef.getSnapshot().context).toEqual({ count: 100, foo: 'win' }); + }); + + it('applies the assignment to the explicit external state (property assignment)', () => { + // const machine = createCounterMachine({ count: 50, foo: 'bar' }); + const machine = createMachine({ + types: {} as { context: CounterContext }, + initial: 'counting', + context: { count: 50, foo: 'bar' }, + states: { + counting: { + on: { + DEC: [ + { + target: 'counting', + // actions: [ + // assign({ + // count: ({ context }) => context.count - 1 + // }) + // ] + actions: (_, _params, enq) => { + enq.assign({ + count: ({ context }) => context.count - 1 + }); + } + } + ] + } + } + } + }); + const actorRef = createActor(machine).start(); + actorRef.send({ type: 'DEC' }); + const oneState = actorRef.getSnapshot(); + + expect(oneState.value).toEqual('counting'); + expect(oneState.context).toEqual({ count: 49, foo: 'bar' }); + + actorRef.send({ type: 'DEC' }); + const twoState = actorRef.getSnapshot(); + + expect(twoState.value).toEqual('counting'); + expect(twoState.context).toEqual({ count: 48, foo: 'bar' }); + + const machine2 = createCounterMachine({ count: 100, foo: 'bar' }); + + const actorRef2 = createActor(machine2).start(); + actorRef2.send({ type: 'DEC' }); + const threeState = actorRef2.getSnapshot(); + + expect(threeState.value).toEqual('counting'); + expect(threeState.context).toEqual({ count: 99, foo: 'bar' }); + }); + + it('applies the assignment to the explicit external state', () => { + const machine = createMachine({ + types: {} as { context: CounterContext }, + initial: 'counting', + context: { count: 50, foo: 'bar' }, + states: { + counting: { + on: { + INC: [ + { + target: 'counting', + actions: (_, _params, enq) => { + enq.assign(({ context }) => ({ + count: context.count + 1 + })); + } + } + ], + DEC: [ + { + target: 'counting', + // actions: [ + // assign({ + // count: ({ context }) => context.count - 1 + // }) + // ] + actions: (_, _params, enq) => { + enq.assign({ + count: ({ context }) => context.count - 1 + }); + } + } + ] + } + } + } + }); + const actorRef = createActor(machine).start(); + actorRef.send({ type: 'INC' }); + const oneState = actorRef.getSnapshot(); + + expect(oneState.value).toEqual('counting'); + expect(oneState.context).toEqual({ count: 51, foo: 'bar' }); + + actorRef.send({ type: 'INC' }); + const twoState = actorRef.getSnapshot(); + + expect(twoState.value).toEqual('counting'); + expect(twoState.context).toEqual({ count: 52, foo: 'bar' }); + + const machine2 = createCounterMachine({ count: 102, foo: 'bar' }); + + const actorRef2 = createActor(machine2).start(); + actorRef2.send({ type: 'INC' }); + const threeState = actorRef2.getSnapshot(); + + expect(threeState.value).toEqual('counting'); + expect(threeState.context).toEqual({ count: 103, foo: 'bar' }); + }); + + it('should maintain state after unhandled event', () => { + const actorRef = createActor(counterMachine).start(); + + actorRef.send({ + type: 'FAKE_EVENT' + }); + const nextState = actorRef.getSnapshot(); + + expect(nextState.context).toBeDefined(); + expect(nextState.context).toEqual({ count: 0, foo: 'bar' }); + }); + + it('sets undefined properties', () => { + const actorRef = createActor(counterMachine).start(); + + actorRef.send({ + type: 'SET_MAYBE' + }); + + const nextState = actorRef.getSnapshot(); + + expect(nextState.context.maybe).toBeDefined(); + expect(nextState.context).toEqual({ + count: 0, + foo: 'bar', + maybe: 'defined' + }); + }); + + it('can assign from event', () => { + const machine = createMachine({ + types: {} as { + context: { count: number }; + events: { type: 'INC'; value: number }; + }, + initial: 'active', + context: { + count: 0 + }, + states: { + active: { + on: { + INC: { + // actions: assign({ + // count: ({ event }) => event.value + // }) + actions: (_, _params, enq) => { + enq.assign({ + count: ({ event }) => event.value + }); + } + } + } + } + } + }); + + const actorRef = createActor(machine).start(); + actorRef.send({ type: 'INC', value: 30 }); + + expect(actorRef.getSnapshot().context.count).toEqual(30); + }); +}); + describe('assign meta', () => { it('should provide the parametrized action to the assigner', () => { const machine = createMachine( @@ -366,3 +732,82 @@ describe('assign meta', () => { service.send({ type: 'EVENT' }); }); }); + +describe('assign meta with inline actions', () => { + it('should provide the parametrized action to the assigner', () => { + const machine = setup({ + types: { + context: {} as { count: number } + }, + actions: { + inc: (_, params: { by: number }, enq) => { + enq.assign(({ context }) => ({ + count: context.count + params.by + })); + } + } + }).createMachine({ + context: { count: 1 }, + entry: { + type: 'inc', + params: { by: 10 } + } + }); + + const actor = createActor(machine).start(); + + expect(actor.getSnapshot().context.count).toEqual(11); + }); + + it('should provide the action parameters to the partial assigner', () => { + const machine = setup({ + types: { + context: {} as { count: number } + }, + actions: { + inc: (_, params: { by: number }, enq) => { + enq.assign({ + count: ({ context }) => context.count + params.by + }); + } + } + }).createMachine({ + context: { count: 1 }, + entry: { + type: 'inc', + params: { by: 10 } + } + }); + + const actor = createActor(machine).start(); + + expect(actor.getSnapshot().context.count).toEqual(11); + }); + + it('a parameterized action that resolves to assign() should be provided the params', (done) => { + const machine = setup({ + actions: { + inc: (_, params: { value: number }, enq) => { + enq.assign(({ context }) => { + expect(params).toEqual({ value: 5 }); + done(); + return context; + }); + } + } + }).createMachine({ + on: { + EVENT: { + actions: { + type: 'inc', + params: { value: 5 } + } + } + } + }); + + const service = createActor(machine).start(); + + service.send({ type: 'EVENT' }); + }); +}); diff --git a/packages/core/test/types.test.ts b/packages/core/test/types.test.ts index af7603468d..fffe5959f4 100644 --- a/packages/core/test/types.test.ts +++ b/packages/core/test/types.test.ts @@ -3476,6 +3476,249 @@ describe('enqueueActions', () => { }); }); +describe('inline actions (prev. enqueueActions)', () => { + it('should be able to enqueue a defined parameterized action with required params', () => { + setup({ + actions: { + greet: (_, params: { name: string }) => { + console.log(`Hello ${params.name}!`); + }, + poke: () => {} + } + }).createMachine({ + entry: (_, _params, enq) => { + enq.action({ + type: 'greet', + params: { + name: 'Anders' + } + }); + } + }); + }); + + it('should not allow to enqueue a defined parameterized action without all of its required params', () => { + setup({ + actions: { + greet: (_, params: { name: string }) => { + console.log(`Hello ${params.name}!`); + }, + poke: () => {} + } + }).createMachine({ + entry: (_, _params, enq) => { + enq.action({ + type: 'greet', + // @ts-expect-error + params: {} + }); + } + }); + }); + + it('should not be possible to enqueue a parameterized action outside of the defined ones', () => { + setup({ + actions: { + greet: (_, params: { name: string }) => { + console.log(`Hello ${params.name}!`); + }, + poke: () => {} + } + }).createMachine({ + entry: (_, _params, enq) => { + enq.action( + // @ts-expect-error + { + type: 'other' + } + ); + } + }); + }); + + it('should be possible to enqueue a parameterized action with no required params using a string', () => { + setup({ + actions: { + greet: (_, params: { name: string }) => { + console.log(`Hello ${params.name}!`); + }, + poke: () => {} + } + }).createMachine({ + entry: (_, _params, enq) => { + enq.action('poke'); + } + }); + }); + + it('should be possible to enqueue a parameterized action with no required params using an object', () => { + setup({ + actions: { + greet: (_, params: { name: string }) => { + console.log(`Hello ${params.name}!`); + }, + poke: () => {} + } + }).createMachine({ + entry: (_, _params, enq) => { + enq.action({ type: 'poke' }); + } + }); + }); + + it('should be able to execute an inline custom action', () => { + setup({ + actions: { + foo: (_, _params: any, enq) => { + enq.action(() => {}); + } + } + }); + }); + + it('should allow a defined simple guard to be checked', () => { + createMachine( + { + types: { + guards: {} as + | { + type: 'isGreaterThan'; + params: { + count: number; + }; + } + | { type: 'plainGuard' } + } + }, + { + actions: { + foo: enqueueActions(({ check }) => { + check('plainGuard'); + }) + } + } + ); + + setup({ + guards: { + plainGuard: () => true, + isGreaterThan: (_, { count }: { count: number }) => count > 10 + }, + actions: { + foo: (_, _params, enq) => { + enq.check('plainGuard'); + } + } + }); + }); + + it('should allow a defined parameterized guard to be checked', () => { + setup({ + guards: { + isGreaterThan: (_, { count }: { count: number }) => count > 10, + plainGuard: () => true + }, + actions: { + foo: (_, _params, enq) => { + enq.check({ + type: 'isGreaterThan', + params: { + count: 10 + } + }); + } + } + }); + }); + + it('should allow a defined parameterized guard to be checked', () => { + setup({ + guards: { + isGreaterThan: (_, { count }: { count: number }) => count > 10, + plainGuard: () => true + }, + actions: { + foo: (_, _params, enq) => { + enq.check( + // @ts-expect-error + { + type: 'other' + } + ); + } + } + }); + }); + + it('should type guard params as undefined in inline custom guard when enqueueActions is used in the config', () => { + setup({ + guards: { + isGreaterThan: (_, { count }: { count: number }) => count > 10, + plainGuard: () => true + }, + actions: { + foo: (_, _params, enq) => { + enq.check((_, params) => { + params satisfies undefined; + undefined satisfies typeof params; + // @ts-expect-error + params satisfies 'not any'; + + return true; + }); + } + } + }); + }); + + it('should be able to enqueue `raise` using its bound action creator in a transition with one of the other accepted event types', () => { + setup({ + types: { + events: {} as + | { + type: 'SOMETHING'; + } + | { + type: 'SOMETHING_ELSE'; + } + } + }).createMachine({ + on: { + SOMETHING: { + actions: (_, _params, enq) => { + enq.raise({ type: 'SOMETHING_ELSE' }); + } + } + } + }); + }); + + it('should not be able to enqueue `raise` using its bound action creator in a transition with an event type that is not defined', () => { + setup({ + types: { + events: {} as + | { + type: 'SOMETHING'; + } + | { + type: 'SOMETHING_ELSE'; + } + } + }).createMachine({ + on: { + SOMETHING: { + actions: (_, _params, enq) => { + enq.raise({ + // @ts-expect-error + type: 'OTHER' + }); + } + } + } + }); + }); +}); + describe('input', () => { it('should provide the input type to the context factory', () => { createMachine({ 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) => {