diff --git a/.changeset/chilly-carpets-draw.md b/.changeset/chilly-carpets-draw.md new file mode 100644 index 0000000000..6c6e124e8e --- /dev/null +++ b/.changeset/chilly-carpets-draw.md @@ -0,0 +1,5 @@ +--- +'xstate': patch +--- + +Add `children` to `Snapshot` interface diff --git a/README.md b/README.md index 913633fd8f..5ce99ad5e6 100644 --- a/README.md +++ b/README.md @@ -181,15 +181,15 @@ 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 | +| 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 | ## Finite State Machines diff --git a/packages/core/src/State.ts b/packages/core/src/State.ts index 32ea67a23a..8bb4d778fb 100644 --- a/packages/core/src/State.ts +++ b/packages/core/src/State.ts @@ -458,7 +458,7 @@ export function getPersistedSnapshot< const persisted = { ...jsonValues, context: persistContext(context) as any, - children: childrenJson + children: childrenJson as Record }; return persisted; diff --git a/packages/core/src/actions/stopChild.ts b/packages/core/src/actions/stopChild.ts index cc3c3e7d87..1317222604 100644 --- a/packages/core/src/actions/stopChild.ts +++ b/packages/core/src/actions/stopChild.ts @@ -67,7 +67,7 @@ function executeStop( // this allows us to prevent an actor from being started if it gets stopped within the same macrostep // this can happen, for example, when the invoking state is being exited immediately by an always transition if (actorRef._processingStatus !== ProcessingStatus.Running) { - actorScope.stopChild(actorRef); + actorScope.stopChild?.(actorRef); return; } // stopping a child enqueues a stop event in the child actor's mailbox @@ -75,7 +75,7 @@ function executeStop( // the parent itself might want to send some events to a child (for example from exit actions on the invoking state) // and we don't want to ignore those events actorScope.defer(() => { - actorScope.stopChild(actorRef); + actorScope.stopChild?.(actorRef); }); } diff --git a/packages/core/src/actors/callback.ts b/packages/core/src/actors/callback.ts index 1dcc84a371..abc5dd87d6 100644 --- a/packages/core/src/actors/callback.ts +++ b/packages/core/src/actors/callback.ts @@ -4,6 +4,7 @@ import { ActorLogic, ActorRefFromLogic, AnyActorRef, + AnyActorScope, AnyEventObject, EventObject, NonReducibleUnknown, @@ -92,6 +93,7 @@ type InvokeCallback< self, sendBack, receive, + spawnChild, emit }: { /** @@ -111,6 +113,7 @@ type InvokeCallback< * listener is then called whenever events are received by the callback actor */ receive: Receiver; + spawnChild: AnyActorScope['spawnChild']; emit: (emitted: TEmitted) => void; }) => (() => void) | void; @@ -191,7 +194,7 @@ export function fromCallback< const logic: CallbackActorLogic = { config: invokeCallback, start: (state, actorScope) => { - const { self, system, emit } = actorScope; + const { self, system, emit, spawnChild } = actorScope; const callbackState: CallbackInstanceState = { receivers: undefined, @@ -216,6 +219,7 @@ export function fromCallback< callbackState.receivers ??= new Set(); callbackState.receivers.add(listener); }, + spawnChild, emit }); }, @@ -244,7 +248,9 @@ export function fromCallback< status: 'active', output: undefined, error: undefined, - input + input, + context: undefined, + children: {} }; }, getPersistedSnapshot: (snapshot) => snapshot, diff --git a/packages/core/src/actors/observable.ts b/packages/core/src/actors/observable.ts index 0dafa0813c..6c9468381b 100644 --- a/packages/core/src/actors/observable.ts +++ b/packages/core/src/actors/observable.ts @@ -2,6 +2,8 @@ import { XSTATE_STOP } from '../constants'; import { AnyActorSystem } from '../system.ts'; import { ActorLogic, + ActorRefFrom, + AnyActorScope, ActorRefFromLogic, EventObject, NonReducibleUnknown, @@ -128,6 +130,7 @@ export function fromObservable< input: TInput; system: AnyActorSystem; self: ObservableActorRef; + spawnChild: AnyActorScope['spawnChild']; emit: (emitted: TEmitted) => void; }) => Subscribable ): ObservableActorLogic { @@ -181,10 +184,11 @@ export function fromObservable< error: undefined, context: undefined, input, - _subscription: undefined + _subscription: undefined, + children: {} }; }, - start: (state, { self, system, emit }) => { + start: (state, { self, system, spawnChild, emit }) => { if (state.status === 'done') { // Do not restart a completed observable return; @@ -193,6 +197,7 @@ export function fromObservable< input: state.input!, system, self, + spawnChild, emit }).subscribe({ next: (value) => { @@ -333,7 +338,8 @@ export function fromEventObservable< error: undefined, context: undefined, input, - _subscription: undefined + _subscription: undefined, + children: {} }; }, start: (state, { self, system, emit }) => { diff --git a/packages/core/src/actors/promise.ts b/packages/core/src/actors/promise.ts index ccc920bc1d..1d60265030 100644 --- a/packages/core/src/actors/promise.ts +++ b/packages/core/src/actors/promise.ts @@ -2,6 +2,8 @@ import { XSTATE_STOP } from '../constants.ts'; import { AnyActorSystem } from '../system.ts'; import { ActorLogic, + ActorRefFrom, + AnyActorScope, ActorRefFromLogic, AnyActorRef, EventObject, @@ -135,6 +137,7 @@ export function fromPromise< system: AnyActorSystem; /** The parent actor of the promise actor */ self: PromiseActorRef; + spawnChild: AnyActorScope['spawnChild']; signal: AbortSignal; emit: (emitted: TEmitted) => void; }) => PromiseLike @@ -175,7 +178,7 @@ export function fromPromise< return state; } }, - start: (state, { self, system, emit }) => { + start: (state, { self, system, spawnChild, emit }) => { // TODO: determine how to allow customizing this so that promises // can be restarted if necessary if (state.status !== 'active') { @@ -189,6 +192,7 @@ export function fromPromise< system, self, signal: controller.signal, + spawnChild, emit }) ); @@ -221,7 +225,9 @@ export function fromPromise< status: 'active', output: undefined, error: undefined, - input + input, + context: undefined, + children: {} }; }, getPersistedSnapshot: (snapshot) => snapshot, diff --git a/packages/core/src/actors/transition.ts b/packages/core/src/actors/transition.ts index 4c92b2c168..98684cd0cc 100644 --- a/packages/core/src/actors/transition.ts +++ b/packages/core/src/actors/transition.ts @@ -8,9 +8,7 @@ import { Snapshot } from '../types.ts'; -export type TransitionSnapshot = Snapshot & { - context: TContext; -}; +export type TransitionSnapshot = Snapshot; export type TransitionActorLogic< TContext, @@ -211,7 +209,8 @@ export function fromTransition< context: typeof initialContext === 'function' ? (initialContext as any)({ input }) - : initialContext + : initialContext, + children: {} }; }, getPersistedSnapshot: (snapshot) => snapshot, diff --git a/packages/core/src/createActor.ts b/packages/core/src/createActor.ts index 3ad250edcc..e13c30451b 100644 --- a/packages/core/src/createActor.ts +++ b/packages/core/src/createActor.ts @@ -12,6 +12,7 @@ import { symbolObservable } from './symbolObservable.ts'; import { AnyActorSystem, Clock, createSystem } from './system.ts'; import type { + ActorRefFrom, ActorScope, AnyActorLogic, AnyActorRef, @@ -169,6 +170,21 @@ export class Actor this._deferred.push(fn); }, system: this.system, + spawnChild: ( + logic: T, + actorOptions?: ActorOptions + ) => { + const actor = createActor(logic, { + parent: this, + ...actorOptions + }); + + if (this._processingStatus === ProcessingStatus.Running) { + actor.start(); + } + + return actor as ActorRefFrom; + }, stopChild: (child) => { if (child._parent !== this) { throw new Error( diff --git a/packages/core/src/getNextSnapshot.ts b/packages/core/src/getNextSnapshot.ts index 7f57aaab0d..3f8d573f8f 100644 --- a/packages/core/src/getNextSnapshot.ts +++ b/packages/core/src/getNextSnapshot.ts @@ -27,7 +27,12 @@ export function createInertActorScope( sessionId: '', stopChild: () => {}, system: self.system, - emit: () => {} + emit: () => {}, + spawnChild(logic) { + const child = createActor(logic) as any; + + return child; + } }; return inertActorScope; diff --git a/packages/core/src/types.ts b/packages/core/src/types.ts index 13fbbcf924..12b48c5cf9 100644 --- a/packages/core/src/types.ts +++ b/packages/core/src/types.ts @@ -2148,7 +2148,11 @@ export interface ActorScope< defer: (fn: () => void) => void; emit: (event: TEmitted) => void; system: TSystem; - stopChild: (child: AnyActorRef) => void; + stopChild?: (child: AnyActorRef) => void; + spawnChild?: ( + logic: T, + actorOptions?: ActorOptions + ) => ActorRefFrom; } export type AnyActorScope = ActorScope< @@ -2160,26 +2164,34 @@ export type AnyActorScope = ActorScope< export type SnapshotStatus = 'active' | 'done' | 'error' | 'stopped'; -export type Snapshot = +export type Snapshot = | { status: 'active'; output: undefined; error: undefined; + context: TContext; + children?: Record; } | { status: 'done'; output: TOutput; error: undefined; + context: TContext; + children?: Record; } | { status: 'error'; output: undefined; error: unknown; + context: TContext; + children?: Record; } | { status: 'stopped'; output: undefined; error: undefined; + context: TContext; + children?: Record; }; /** diff --git a/packages/core/test/actor.test.ts b/packages/core/test/actor.test.ts index d21488fc00..aaa51f9a5e 100644 --- a/packages/core/test/actor.test.ts +++ b/packages/core/test/actor.test.ts @@ -1158,7 +1158,9 @@ describe('actors', () => { getInitialSnapshot: () => ({ status: 'active', output: undefined, - error: undefined + error: undefined, + context: undefined, + children: {} }), getPersistedSnapshot: (s) => s }; diff --git a/packages/core/test/actorLogic.test.ts b/packages/core/test/actorLogic.test.ts index 7f37a7f145..9fdd514e0e 100644 --- a/packages/core/test/actorLogic.test.ts +++ b/packages/core/test/actorLogic.test.ts @@ -134,13 +134,15 @@ describe('promise logic (fromPromise)', () => { const resolvedPersistedState = actor.getPersistedSnapshot(); expect(resolvedPersistedState).toMatchInlineSnapshot(` - { - "error": undefined, - "input": undefined, - "output": 42, - "status": "done", - } - `); +{ + "children": {}, + "context": undefined, + "error": undefined, + "input": undefined, + "output": 42, + "status": "done", +} +`); const restoredActor = createActor(promiseLogic, { snapshot: resolvedPersistedState @@ -163,13 +165,15 @@ describe('promise logic (fromPromise)', () => { const resolvedPersistedState = actor.getPersistedSnapshot(); expect(resolvedPersistedState).toMatchInlineSnapshot(` - { - "error": undefined, - "input": undefined, - "output": 1, - "status": "done", - } - `); +{ + "children": {}, + "context": undefined, + "error": undefined, + "input": undefined, + "output": 1, + "status": "done", +} +`); expect(createdPromises).toBe(1); const restoredActor = createActor(promiseLogic, { @@ -194,13 +198,15 @@ describe('promise logic (fromPromise)', () => { const rejectedPersistedState = actorRef.getPersistedSnapshot(); expect(rejectedPersistedState).toMatchInlineSnapshot(` - { - "error": 1, - "input": undefined, - "output": undefined, - "status": "error", - } - `); +{ + "children": {}, + "context": undefined, + "error": 1, + "input": undefined, + "output": undefined, + "status": "error", +} +`); expect(createdPromises).toBe(1); const actorRef2 = createActor(promiseLogic, { @@ -513,7 +519,8 @@ describe('transition function logic (fromTransition)', () => { error: undefined, context: { enabled: 'on' - } + }, + children: {} }); const restoredActor = createActor(logic, { snapshot: persistedSnapshot }); @@ -839,13 +846,15 @@ describe('machine logic', () => { const persistedState = actor.getPersistedSnapshot()!; expect((persistedState as any).children.a.snapshot).toMatchInlineSnapshot(` - { - "error": undefined, - "input": undefined, - "output": 42, - "status": "done", - } - `); +{ + "children": {}, + "context": undefined, + "error": undefined, + "input": undefined, + "output": 42, + "status": "done", +} +`); expect((persistedState as any).children.b.snapshot).toEqual( expect.objectContaining({ @@ -855,9 +864,9 @@ describe('machine logic', () => { value: 'start', children: { reducer: expect.objectContaining({ - snapshot: { + snapshot: expect.objectContaining({ status: 'active' - } + }) }) } }) diff --git a/packages/core/test/errors.test.ts b/packages/core/test/errors.test.ts index 0a6cb0d1a1..5f1e021b3d 100644 --- a/packages/core/test/errors.test.ts +++ b/packages/core/test/errors.test.ts @@ -833,7 +833,8 @@ describe('error handling', () => { status: 'error', output: undefined, error: 'immediate error!', - context: undefined + context: undefined, + children: {} }); const machine = createMachine( diff --git a/packages/core/test/inspect.test.ts b/packages/core/test/inspect.test.ts index f199f9d7df..e473c961ab 100644 --- a/packages/core/test/inspect.test.ts +++ b/packages/core/test/inspect.test.ts @@ -240,214 +240,218 @@ describe('inspect', () => { ['@xstate.actor', '@xstate.event', '@xstate.snapshot'].includes(ev.type) ) ).toMatchInlineSnapshot(` - [ - { - "actorId": "x:1", - "type": "@xstate.actor", - }, - { - "actorId": "x:2", - "type": "@xstate.actor", - }, - { - "event": { - "input": undefined, - "type": "xstate.init", - }, - "sourceId": undefined, - "targetId": "x:1", - "type": "@xstate.event", - }, - { - "event": { - "input": undefined, - "type": "xstate.init", - }, - "sourceId": "x:1", - "targetId": "x:2", - "type": "@xstate.event", - }, - { - "actorId": "x:2", - "event": { - "input": undefined, - "type": "xstate.init", - }, - "snapshot": { - "value": "start", - }, - "status": "active", - "type": "@xstate.snapshot", - }, - { - "actorId": "x:1", - "event": { - "input": undefined, - "type": "xstate.init", - }, - "snapshot": { - "value": "waiting", - }, - "status": "active", - "type": "@xstate.snapshot", - }, - { - "event": { - "type": "load", - }, - "sourceId": undefined, - "targetId": "x:1", - "type": "@xstate.event", - }, - { - "event": { - "type": "loadChild", - }, - "sourceId": "x:1", - "targetId": "x:2", - "type": "@xstate.event", - }, - { - "actorId": "x:3", - "type": "@xstate.actor", - }, - { - "event": { - "input": undefined, - "type": "xstate.init", - }, - "sourceId": "x:2", - "targetId": "x:3", - "type": "@xstate.event", - }, - { - "actorId": "x:3", - "event": { - "input": undefined, - "type": "xstate.init", - }, - "snapshot": { - "error": undefined, - "input": undefined, - "output": undefined, - "status": "active", - }, - "status": "active", - "type": "@xstate.snapshot", - }, - { - "actorId": "x:2", - "event": { - "type": "loadChild", - }, - "snapshot": { - "value": "loading", - }, - "status": "active", - "type": "@xstate.snapshot", - }, - { - "actorId": "x:1", - "event": { - "type": "load", - }, - "snapshot": { - "value": "waiting", - }, - "status": "active", - "type": "@xstate.snapshot", - }, - { - "event": { - "data": 42, - "type": "xstate.promise.resolve", - }, - "sourceId": "x:3", - "targetId": "x:3", - "type": "@xstate.event", - }, - { - "event": { - "actorId": "0.(machine).loading", - "output": 42, - "type": "xstate.done.actor.0.(machine).loading", - }, - "sourceId": "x:3", - "targetId": "x:2", - "type": "@xstate.event", - }, - { - "event": { - "type": "toParent", - }, - "sourceId": "x:2", - "targetId": "x:1", - "type": "@xstate.event", - }, - { - "actorId": "x:1", - "event": { - "type": "toParent", - }, - "snapshot": { - "value": "waiting", - }, - "status": "active", - "type": "@xstate.snapshot", - }, - { - "event": { - "actorId": "child", - "output": undefined, - "type": "xstate.done.actor.child", - }, - "sourceId": "x:2", - "targetId": "x:1", - "type": "@xstate.event", - }, - { - "actorId": "x:1", - "event": { - "actorId": "child", - "output": undefined, - "type": "xstate.done.actor.child", - }, - "snapshot": { - "value": "success", - }, - "status": "active", - "type": "@xstate.snapshot", - }, - { - "actorId": "x:2", - "event": { - "actorId": "0.(machine).loading", - "output": 42, - "type": "xstate.done.actor.0.(machine).loading", - }, - "snapshot": { - "value": "loaded", - }, - "status": "done", - "type": "@xstate.snapshot", - }, - { - "actorId": "x:3", - "event": { - "data": 42, - "type": "xstate.promise.resolve", - }, - "snapshot": { - "error": undefined, - "input": undefined, - "output": 42, - "status": "done", - }, - "status": "done", - "type": "@xstate.snapshot", - }, - ] - `); +[ + { + "actorId": "x:1", + "type": "@xstate.actor", + }, + { + "actorId": "x:2", + "type": "@xstate.actor", + }, + { + "event": { + "input": undefined, + "type": "xstate.init", + }, + "sourceId": undefined, + "targetId": "x:1", + "type": "@xstate.event", + }, + { + "event": { + "input": undefined, + "type": "xstate.init", + }, + "sourceId": "x:1", + "targetId": "x:2", + "type": "@xstate.event", + }, + { + "actorId": "x:2", + "event": { + "input": undefined, + "type": "xstate.init", + }, + "snapshot": { + "value": "start", + }, + "status": "active", + "type": "@xstate.snapshot", + }, + { + "actorId": "x:1", + "event": { + "input": undefined, + "type": "xstate.init", + }, + "snapshot": { + "value": "waiting", + }, + "status": "active", + "type": "@xstate.snapshot", + }, + { + "event": { + "type": "load", + }, + "sourceId": undefined, + "targetId": "x:1", + "type": "@xstate.event", + }, + { + "event": { + "type": "loadChild", + }, + "sourceId": "x:1", + "targetId": "x:2", + "type": "@xstate.event", + }, + { + "actorId": "x:3", + "type": "@xstate.actor", + }, + { + "event": { + "input": undefined, + "type": "xstate.init", + }, + "sourceId": "x:2", + "targetId": "x:3", + "type": "@xstate.event", + }, + { + "actorId": "x:3", + "event": { + "input": undefined, + "type": "xstate.init", + }, + "snapshot": { + "children": {}, + "context": undefined, + "error": undefined, + "input": undefined, + "output": undefined, + "status": "active", + }, + "status": "active", + "type": "@xstate.snapshot", + }, + { + "actorId": "x:2", + "event": { + "type": "loadChild", + }, + "snapshot": { + "value": "loading", + }, + "status": "active", + "type": "@xstate.snapshot", + }, + { + "actorId": "x:1", + "event": { + "type": "load", + }, + "snapshot": { + "value": "waiting", + }, + "status": "active", + "type": "@xstate.snapshot", + }, + { + "event": { + "data": 42, + "type": "xstate.promise.resolve", + }, + "sourceId": "x:3", + "targetId": "x:3", + "type": "@xstate.event", + }, + { + "event": { + "actorId": "0.(machine).loading", + "output": 42, + "type": "xstate.done.actor.0.(machine).loading", + }, + "sourceId": "x:3", + "targetId": "x:2", + "type": "@xstate.event", + }, + { + "event": { + "type": "toParent", + }, + "sourceId": "x:2", + "targetId": "x:1", + "type": "@xstate.event", + }, + { + "actorId": "x:1", + "event": { + "type": "toParent", + }, + "snapshot": { + "value": "waiting", + }, + "status": "active", + "type": "@xstate.snapshot", + }, + { + "event": { + "actorId": "child", + "output": undefined, + "type": "xstate.done.actor.child", + }, + "sourceId": "x:2", + "targetId": "x:1", + "type": "@xstate.event", + }, + { + "actorId": "x:1", + "event": { + "actorId": "child", + "output": undefined, + "type": "xstate.done.actor.child", + }, + "snapshot": { + "value": "success", + }, + "status": "active", + "type": "@xstate.snapshot", + }, + { + "actorId": "x:2", + "event": { + "actorId": "0.(machine).loading", + "output": 42, + "type": "xstate.done.actor.0.(machine).loading", + }, + "snapshot": { + "value": "loaded", + }, + "status": "done", + "type": "@xstate.snapshot", + }, + { + "actorId": "x:3", + "event": { + "data": 42, + "type": "xstate.promise.resolve", + }, + "snapshot": { + "children": {}, + "context": undefined, + "error": undefined, + "input": undefined, + "output": 42, + "status": "done", + }, + "status": "done", + "type": "@xstate.snapshot", + }, +] +`); }); it('can inspect microsteps from always events', async () => { diff --git a/packages/core/test/invoke.test.ts b/packages/core/test/invoke.test.ts index 344b67cfe2..dc121b31f2 100644 --- a/packages/core/test/invoke.test.ts +++ b/packages/core/test/invoke.test.ts @@ -2265,7 +2265,8 @@ describe('invoke', () => { status: 'active', output: undefined, error: undefined, - context: 0 + context: 0, + children: {} }), getPersistedSnapshot: (s) => s }; @@ -2306,7 +2307,9 @@ describe('invoke', () => { getInitialSnapshot: () => ({ status: 'active', output: undefined, - error: undefined + error: undefined, + context: undefined, + children: {} }), getPersistedSnapshot: (s) => s }; diff --git a/packages/core/test/typeHelpers.test.ts b/packages/core/test/typeHelpers.test.ts index 84c7754176..d49d2e59e9 100644 --- a/packages/core/test/typeHelpers.test.ts +++ b/packages/core/test/typeHelpers.test.ts @@ -195,7 +195,9 @@ describe('ActorRefFrom', () => { getInitialSnapshot: () => ({ status: 'active', output: undefined, - error: undefined + error: undefined, + context: undefined, + children: {} }), getPersistedSnapshot: (s) => s };