From a08f2ee91f36b2c0b23c4ffaa097425a61511f08 Mon Sep 17 00:00:00 2001 From: David Khourshid Date: Sat, 3 Feb 2024 22:05:59 -0500 Subject: [PATCH 01/10] Add ability to spawn actor from any logic --- packages/core/src/actors/callback.ts | 8 ++- packages/core/src/actors/observable.ts | 7 +- packages/core/src/actors/promise.ts | 6 +- packages/core/src/actors/transition.ts | 8 ++- packages/core/src/createActor.ts | 7 ++ packages/core/src/types.ts | 1 + packages/core/src/utils.ts | 9 +++ packages/core/test/actorLogic.test.ts | 91 +++++++++++++++++++++++++- 8 files changed, 127 insertions(+), 10 deletions(-) diff --git a/packages/core/src/actors/callback.ts b/packages/core/src/actors/callback.ts index 4c9c7bfd23..6d8a65d7a1 100644 --- a/packages/core/src/actors/callback.ts +++ b/packages/core/src/actors/callback.ts @@ -3,6 +3,7 @@ import { AnyActorSystem } from '../system.ts'; import { ActorLogic, ActorRefFrom, + ActorScope, AnyActorRef, AnyEventObject, EventObject, @@ -49,7 +50,8 @@ export type InvokeCallback< system, self, sendBack, - receive + receive, + spawn }: { /** * Data that was provided to the callback actor @@ -73,6 +75,7 @@ export type InvokeCallback< * the listener is then called whenever events are received by the callback actor */ receive: Receiver; + spawn: ActorScope['spawn']; }) => (() => void) | void; /** @@ -165,7 +168,8 @@ export function fromCallback< receive: (listener) => { callbackState.receivers ??= new Set(); callbackState.receivers.add(listener); - } + }, + spawn: actorScope.spawn }); }, transition: (state, event, actorScope) => { diff --git a/packages/core/src/actors/observable.ts b/packages/core/src/actors/observable.ts index 99b3d29e13..152da7433c 100644 --- a/packages/core/src/actors/observable.ts +++ b/packages/core/src/actors/observable.ts @@ -3,6 +3,7 @@ import { AnyActorSystem } from '../system.ts'; import { ActorLogic, ActorRefFrom, + ActorScope, EventObject, NonReducibleUnknown, Snapshot, @@ -85,6 +86,7 @@ export function fromObservable( input: TInput; system: AnyActorSystem; self: ObservableActorRef; + spawn: ActorScope['spawn']; }) => Subscribable ): ObservableActorLogic { // TODO: add event types @@ -140,7 +142,7 @@ export function fromObservable( _subscription: undefined }; }, - start: (state, { self, system }) => { + start: (state, { self, system, spawn }) => { if (state.status === 'done') { // Do not restart a completed observable return; @@ -148,7 +150,8 @@ export function fromObservable( state._subscription = observableCreator({ input: state.input!, system, - self + self, + spawn }).subscribe({ next: (value) => { system._relay(self, self, { diff --git a/packages/core/src/actors/promise.ts b/packages/core/src/actors/promise.ts index 65d9c0d630..a00a9d326c 100644 --- a/packages/core/src/actors/promise.ts +++ b/packages/core/src/actors/promise.ts @@ -3,6 +3,7 @@ import { AnyActorSystem } from '../system.ts'; import { ActorLogic, ActorRefFrom, + ActorScope, NonReducibleUnknown, Snapshot } from '../types.ts'; @@ -86,6 +87,7 @@ export function fromPromise( * The parent actor of the promise actor */ self: PromiseActorRef; + spawn: ActorScope['spawn']; }) => PromiseLike ): PromiseActorLogic { const logic: PromiseActorLogic = { @@ -122,7 +124,7 @@ export function fromPromise( return state; } }, - start: (state, { self, system }) => { + start: (state, { self, system, spawn }) => { // TODO: determine how to allow customizing this so that promises // can be restarted if necessary if (state.status !== 'active') { @@ -130,7 +132,7 @@ export function fromPromise( } const resolvedPromise = Promise.resolve( - promiseCreator({ input: state.input!, system, self }) + promiseCreator({ input: state.input!, system, self, spawn }) ); resolvedPromise.then( diff --git a/packages/core/src/actors/transition.ts b/packages/core/src/actors/transition.ts index f4da5a2718..b29737c553 100644 --- a/packages/core/src/actors/transition.ts +++ b/packages/core/src/actors/transition.ts @@ -98,10 +98,12 @@ export function fromTransition< | TContext | (({ input, - self + self, + spawn }: { input: TInput; self: TransitionActorRef; + spawn: ActorScope['spawn']; }) => TContext) // TODO: type ): TransitionActorLogic { return { @@ -116,14 +118,14 @@ export function fromTransition< ) }; }, - getInitialSnapshot: (_, input) => { + getInitialSnapshot: ({ self, spawn }, input) => { return { status: 'active', output: undefined, error: undefined, context: typeof initialContext === 'function' - ? (initialContext as any)({ input }) + ? (initialContext as any)({ input, self, spawn }) : initialContext }; }, diff --git a/packages/core/src/createActor.ts b/packages/core/src/createActor.ts index 26c0bbf0fa..968d374381 100644 --- a/packages/core/src/createActor.ts +++ b/packages/core/src/createActor.ts @@ -178,6 +178,13 @@ export class Actor ); } (child as any)._stop(); + }, + spawn: (logic) => { + const actor = createActor(logic, { + parent: this + }); + + return actor; } }; diff --git a/packages/core/src/types.ts b/packages/core/src/types.ts index b64281dc7a..a06f26ce04 100644 --- a/packages/core/src/types.ts +++ b/packages/core/src/types.ts @@ -2106,6 +2106,7 @@ export interface ActorScope< defer: (fn: () => void) => void; system: TSystem; stopChild: (child: AnyActorRef) => void; + spawn: (logic: AnyActorLogic) => AnyActorRef; } export type AnyActorScope = ActorScope; diff --git a/packages/core/src/utils.ts b/packages/core/src/utils.ts index 900e0e9265..b921079615 100644 --- a/packages/core/src/utils.ts +++ b/packages/core/src/utils.ts @@ -206,6 +206,15 @@ export function isActorLogic(value: any): value is ActorLogic { ); } +export function isActorRef(value: any): value is AnyActorRef { + return ( + value !== null && + typeof value === 'object' && + 'send' in value && + typeof value.send === 'function' + ); +} + export function isArray(value: any): value is readonly any[] { return Array.isArray(value); } diff --git a/packages/core/test/actorLogic.test.ts b/packages/core/test/actorLogic.test.ts index 9cba36d698..be8f47809c 100644 --- a/packages/core/test/actorLogic.test.ts +++ b/packages/core/test/actorLogic.test.ts @@ -6,7 +6,8 @@ import { createActor, AnyActorLogic, Snapshot, - ActorLogic + ActorLogic, + toPromise } from '../src/index.ts'; import { fromCallback, @@ -17,6 +18,7 @@ import { } from '../src/actors/index.ts'; import { waitFor } from '../src/waitFor.ts'; import { raise, sendTo } from '../src/actions.ts'; +import { isActorRef } from '../src/utils.ts'; describe('promise logic (fromPromise)', () => { it('should interpret a promise', async () => { @@ -232,6 +234,20 @@ describe('promise logic (fromPromise)', () => { createActor(promiseLogic).start(); }); + + it('can spawn an actor', () => { + expect.assertions(1); + const promiseLogic = fromPromise(({ spawn }) => { + const childActor = spawn(fromPromise(() => Promise.resolve(42))); + return Promise.resolve(childActor); + }); + + const actor = createActor(promiseLogic).start(); + + toPromise(actor).then((res) => { + expect(isActorRef(res)).toBeTruthy(); + }); + }); }); describe('transition function logic (fromTransition)', () => { @@ -314,6 +330,47 @@ describe('transition function logic (fromTransition)', () => { actor.send({ type: 'a' }); }); + + it('can spawn an actor when receiving an event', () => { + expect.assertions(1); + const transitionLogic = fromTransition< + AnyActorRef | undefined, + any, + any, + any + >((_state, _event, { spawn }) => { + const childActor = spawn(fromPromise(() => Promise.resolve(42))); + return childActor; + }, undefined); + + const actor = createActor(transitionLogic).start(); + actor.send({ type: 'anyEvent' }); + + expect(isActorRef(actor.getSnapshot().context)).toBeTruthy(); + }); + + it('can spawn an actor upon start', () => { + expect.assertions(1); + const transitionLogic = fromTransition< + AnyActorRef | undefined, + any, + any, + any + >( + (state) => { + return state; + }, + ({ spawn }) => { + const childActor = spawn(fromPromise(() => Promise.resolve(42))); + return childActor; + } + ); + + const actor = createActor(transitionLogic).start(); + actor.send({ type: 'anyEvent' }); + + expect(isActorRef(actor.getSnapshot().context)).toBeTruthy(); + }); }); describe('observable logic (fromObservable)', () => { @@ -416,6 +473,17 @@ describe('observable logic (fromObservable)', () => { createActor(observableLogic).start(); }); + + it('can spawn an actor', () => { + expect.assertions(1); + const observableLogic = fromObservable(({ spawn }) => { + const actorRef = spawn(fromPromise(() => Promise.resolve(42))); + expect(isActorRef(actorRef)).toBe(true); + return of(actorRef); + }); + + createActor(observableLogic).start(); + }); }); describe('eventObservable logic (fromEventObservable)', () => { @@ -438,6 +506,17 @@ describe('eventObservable logic (fromEventObservable)', () => { createActor(observableLogic).start(); }); + + it('can spawn an actor', () => { + expect.assertions(1); + const observableLogic = fromObservable(({ spawn }) => { + const actorRef = spawn(fromPromise(() => Promise.resolve(42))); + expect(isActorRef(actorRef)).toBe(true); + return of({ type: 'a', payload: actorRef }); + }); + + createActor(observableLogic).start(); + }); }); describe('callback logic (fromCallback)', () => { @@ -556,6 +635,16 @@ describe('callback logic (fromCallback)', () => { expect(spy).toHaveBeenCalledTimes(1); expect(spy).toHaveBeenCalledWith(13); }); + + it('can spawn an actor', () => { + expect.assertions(1); + const callbackLogic = fromCallback(({ spawn }) => { + const actorRef = spawn(fromPromise(() => Promise.resolve(42))); + expect(isActorRef(actorRef)).toBe(true); + }); + + createActor(callbackLogic).start(); + }); }); describe('machine logic', () => { From 873ef21645c30ad4c08eaa21bf51ee4d42eae25b Mon Sep 17 00:00:00 2001 From: David Khourshid Date: Mon, 5 Feb 2024 14:50:34 -0500 Subject: [PATCH 02/10] Address comments --- packages/core/src/actors/callback.ts | 4 ++-- packages/core/src/actors/observable.ts | 4 ++-- packages/core/src/actors/promise.ts | 4 ++-- packages/core/src/actors/transition.ts | 4 ++-- packages/core/src/createActor.ts | 11 ++++++++--- packages/core/src/getNextSnapshot.ts | 5 ++++- packages/core/src/types.ts | 5 ++++- packages/core/test/actorLogic.test.ts | 2 +- packages/xstate-graph/src/actorScope.ts | 3 ++- 9 files changed, 27 insertions(+), 15 deletions(-) diff --git a/packages/core/src/actors/callback.ts b/packages/core/src/actors/callback.ts index 6d8a65d7a1..21b0d77d81 100644 --- a/packages/core/src/actors/callback.ts +++ b/packages/core/src/actors/callback.ts @@ -75,7 +75,7 @@ export type InvokeCallback< * the listener is then called whenever events are received by the callback actor */ receive: Receiver; - spawn: ActorScope['spawn']; + spawn: ActorScope['spawnChild']; }) => (() => void) | void; /** @@ -169,7 +169,7 @@ export function fromCallback< callbackState.receivers ??= new Set(); callbackState.receivers.add(listener); }, - spawn: actorScope.spawn + spawn: actorScope.spawnChild }); }, transition: (state, event, actorScope) => { diff --git a/packages/core/src/actors/observable.ts b/packages/core/src/actors/observable.ts index 152da7433c..f506ea9de3 100644 --- a/packages/core/src/actors/observable.ts +++ b/packages/core/src/actors/observable.ts @@ -86,7 +86,7 @@ export function fromObservable( input: TInput; system: AnyActorSystem; self: ObservableActorRef; - spawn: ActorScope['spawn']; + spawn: ActorScope['spawnChild']; }) => Subscribable ): ObservableActorLogic { // TODO: add event types @@ -142,7 +142,7 @@ export function fromObservable( _subscription: undefined }; }, - start: (state, { self, system, spawn }) => { + start: (state, { self, system, spawnChild: spawn }) => { if (state.status === 'done') { // Do not restart a completed observable return; diff --git a/packages/core/src/actors/promise.ts b/packages/core/src/actors/promise.ts index a00a9d326c..7096b164b7 100644 --- a/packages/core/src/actors/promise.ts +++ b/packages/core/src/actors/promise.ts @@ -87,7 +87,7 @@ export function fromPromise( * The parent actor of the promise actor */ self: PromiseActorRef; - spawn: ActorScope['spawn']; + spawn: ActorScope['spawnChild']; }) => PromiseLike ): PromiseActorLogic { const logic: PromiseActorLogic = { @@ -124,7 +124,7 @@ export function fromPromise( return state; } }, - start: (state, { self, system, spawn }) => { + start: (state, { self, system, spawnChild: spawn }) => { // TODO: determine how to allow customizing this so that promises // can be restarted if necessary if (state.status !== 'active') { diff --git a/packages/core/src/actors/transition.ts b/packages/core/src/actors/transition.ts index b29737c553..63453383fe 100644 --- a/packages/core/src/actors/transition.ts +++ b/packages/core/src/actors/transition.ts @@ -103,7 +103,7 @@ export function fromTransition< }: { input: TInput; self: TransitionActorRef; - spawn: ActorScope['spawn']; + spawn: ActorScope['spawnChild']; }) => TContext) // TODO: type ): TransitionActorLogic { return { @@ -118,7 +118,7 @@ export function fromTransition< ) }; }, - getInitialSnapshot: ({ self, spawn }, input) => { + getInitialSnapshot: ({ self, spawnChild: spawn }, input) => { return { status: 'active', output: undefined, diff --git a/packages/core/src/createActor.ts b/packages/core/src/createActor.ts index 968d374381..e959d290e0 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, ConditionalRequired, @@ -179,12 +180,16 @@ export class Actor } (child as any)._stop(); }, - spawn: (logic) => { + spawnChild: ( + logic: T, + actorOptions?: ActorOptions + ) => { const actor = createActor(logic, { - parent: this + parent: this, + ...actorOptions }); - return actor; + return actor as ActorRefFrom; } }; diff --git a/packages/core/src/getNextSnapshot.ts b/packages/core/src/getNextSnapshot.ts index 0ef191fed0..928de4b1b7 100644 --- a/packages/core/src/getNextSnapshot.ts +++ b/packages/core/src/getNextSnapshot.ts @@ -20,7 +20,10 @@ export function createInertActorScope( logger: () => {}, sessionId: '', stopChild: () => {}, - system: self.system + system: self.system, + spawnChild: (logic) => { + return createActor(logic) as any; + } }; return inertActorScope; diff --git a/packages/core/src/types.ts b/packages/core/src/types.ts index a06f26ce04..b9ec69caed 100644 --- a/packages/core/src/types.ts +++ b/packages/core/src/types.ts @@ -2106,7 +2106,10 @@ export interface ActorScope< defer: (fn: () => void) => void; system: TSystem; stopChild: (child: AnyActorRef) => void; - spawn: (logic: AnyActorLogic) => AnyActorRef; + spawnChild: ( + logic: T, + actorOptions?: ActorOptions + ) => ActorRefFrom; } export type AnyActorScope = ActorScope; diff --git a/packages/core/test/actorLogic.test.ts b/packages/core/test/actorLogic.test.ts index be8f47809c..4233410fa8 100644 --- a/packages/core/test/actorLogic.test.ts +++ b/packages/core/test/actorLogic.test.ts @@ -338,7 +338,7 @@ describe('transition function logic (fromTransition)', () => { any, any, any - >((_state, _event, { spawn }) => { + >((_state, _event, { spawnChild: spawn }) => { const childActor = spawn(fromPromise(() => Promise.resolve(42))); return childActor; }, undefined); diff --git a/packages/xstate-graph/src/actorScope.ts b/packages/xstate-graph/src/actorScope.ts index a81213c69d..8a9ce54d07 100644 --- a/packages/xstate-graph/src/actorScope.ts +++ b/packages/xstate-graph/src/actorScope.ts @@ -9,6 +9,7 @@ export function createMockActorScope(): AnyActorScope { sessionId: Math.random().toString(32).slice(2), defer: () => {}, system: emptyActor.system, // TODO: mock system? - stopChild: () => {} + stopChild: () => {}, + spawnChild: () => emptyActor as any }; } From a6f4f3e351cafa14ecdbb83cddee81c25d7e7423 Mon Sep 17 00:00:00 2001 From: David Khourshid Date: Tue, 6 Feb 2024 08:13:31 -0500 Subject: [PATCH 03/10] spawn -> spawnChild --- packages/core/src/actors/observable.ts | 6 +++--- packages/core/src/actors/promise.ts | 11 ++++++++--- packages/core/src/actors/transition.ts | 8 ++++---- packages/core/src/createActor.ts | 4 ++++ packages/core/test/actorLogic.test.ts | 20 ++++++++++---------- 5 files changed, 29 insertions(+), 20 deletions(-) diff --git a/packages/core/src/actors/observable.ts b/packages/core/src/actors/observable.ts index f506ea9de3..8fdb8ed9eb 100644 --- a/packages/core/src/actors/observable.ts +++ b/packages/core/src/actors/observable.ts @@ -86,7 +86,7 @@ export function fromObservable( input: TInput; system: AnyActorSystem; self: ObservableActorRef; - spawn: ActorScope['spawnChild']; + spawnChild: ActorScope['spawnChild']; }) => Subscribable ): ObservableActorLogic { // TODO: add event types @@ -142,7 +142,7 @@ export function fromObservable( _subscription: undefined }; }, - start: (state, { self, system, spawnChild: spawn }) => { + start: (state, { self, system, spawnChild }) => { if (state.status === 'done') { // Do not restart a completed observable return; @@ -151,7 +151,7 @@ export function fromObservable( input: state.input!, system, self, - spawn + spawnChild }).subscribe({ next: (value) => { system._relay(self, self, { diff --git a/packages/core/src/actors/promise.ts b/packages/core/src/actors/promise.ts index 7096b164b7..10bf7642da 100644 --- a/packages/core/src/actors/promise.ts +++ b/packages/core/src/actors/promise.ts @@ -87,7 +87,7 @@ export function fromPromise( * The parent actor of the promise actor */ self: PromiseActorRef; - spawn: ActorScope['spawnChild']; + spawnChild: ActorScope['spawnChild']; }) => PromiseLike ): PromiseActorLogic { const logic: PromiseActorLogic = { @@ -124,7 +124,7 @@ export function fromPromise( return state; } }, - start: (state, { self, system, spawnChild: spawn }) => { + start: (state, { self, system, spawnChild }) => { // TODO: determine how to allow customizing this so that promises // can be restarted if necessary if (state.status !== 'active') { @@ -132,7 +132,12 @@ export function fromPromise( } const resolvedPromise = Promise.resolve( - promiseCreator({ input: state.input!, system, self, spawn }) + promiseCreator({ + input: state.input!, + system, + self, + spawnChild + }) ); resolvedPromise.then( diff --git a/packages/core/src/actors/transition.ts b/packages/core/src/actors/transition.ts index 63453383fe..1465beaf07 100644 --- a/packages/core/src/actors/transition.ts +++ b/packages/core/src/actors/transition.ts @@ -99,11 +99,11 @@ export function fromTransition< | (({ input, self, - spawn + spawnChild }: { input: TInput; self: TransitionActorRef; - spawn: ActorScope['spawnChild']; + spawnChild: ActorScope['spawnChild']; }) => TContext) // TODO: type ): TransitionActorLogic { return { @@ -118,14 +118,14 @@ export function fromTransition< ) }; }, - getInitialSnapshot: ({ self, spawnChild: spawn }, input) => { + getInitialSnapshot: ({ self, spawnChild }, input) => { return { status: 'active', output: undefined, error: undefined, context: typeof initialContext === 'function' - ? (initialContext as any)({ input, self, spawn }) + ? (initialContext as any)({ input, self, spawnChild }) : initialContext }; }, diff --git a/packages/core/src/createActor.ts b/packages/core/src/createActor.ts index e959d290e0..a34fcc4e4f 100644 --- a/packages/core/src/createActor.ts +++ b/packages/core/src/createActor.ts @@ -189,6 +189,10 @@ export class Actor ...actorOptions }); + if (this._processingStatus === ProcessingStatus.Running) { + actor.start(); + } + return actor as ActorRefFrom; } }; diff --git a/packages/core/test/actorLogic.test.ts b/packages/core/test/actorLogic.test.ts index 4233410fa8..afafd69006 100644 --- a/packages/core/test/actorLogic.test.ts +++ b/packages/core/test/actorLogic.test.ts @@ -237,8 +237,8 @@ describe('promise logic (fromPromise)', () => { it('can spawn an actor', () => { expect.assertions(1); - const promiseLogic = fromPromise(({ spawn }) => { - const childActor = spawn(fromPromise(() => Promise.resolve(42))); + const promiseLogic = fromPromise(({ spawnChild }) => { + const childActor = spawnChild(fromPromise(() => Promise.resolve(42))); return Promise.resolve(childActor); }); @@ -338,8 +338,8 @@ describe('transition function logic (fromTransition)', () => { any, any, any - >((_state, _event, { spawnChild: spawn }) => { - const childActor = spawn(fromPromise(() => Promise.resolve(42))); + >((_state, _event, { spawnChild }) => { + const childActor = spawnChild(fromPromise(() => Promise.resolve(42))); return childActor; }, undefined); @@ -360,8 +360,8 @@ describe('transition function logic (fromTransition)', () => { (state) => { return state; }, - ({ spawn }) => { - const childActor = spawn(fromPromise(() => Promise.resolve(42))); + ({ spawnChild }) => { + const childActor = spawnChild(fromPromise(() => Promise.resolve(42))); return childActor; } ); @@ -476,8 +476,8 @@ describe('observable logic (fromObservable)', () => { it('can spawn an actor', () => { expect.assertions(1); - const observableLogic = fromObservable(({ spawn }) => { - const actorRef = spawn(fromPromise(() => Promise.resolve(42))); + const observableLogic = fromObservable(({ spawnChild }) => { + const actorRef = spawnChild(fromPromise(() => Promise.resolve(42))); expect(isActorRef(actorRef)).toBe(true); return of(actorRef); }); @@ -509,8 +509,8 @@ describe('eventObservable logic (fromEventObservable)', () => { it('can spawn an actor', () => { expect.assertions(1); - const observableLogic = fromObservable(({ spawn }) => { - const actorRef = spawn(fromPromise(() => Promise.resolve(42))); + const observableLogic = fromObservable(({ spawnChild }) => { + const actorRef = spawnChild(fromPromise(() => Promise.resolve(42))); expect(isActorRef(actorRef)).toBe(true); return of({ type: 'a', payload: actorRef }); }); From 697e1b0eadafdc111855fc2c950b00cd316541bd Mon Sep 17 00:00:00 2001 From: David Khourshid Date: Fri, 9 Feb 2024 13:59:05 -0500 Subject: [PATCH 04/10] Oops, infinite loop --- packages/core/src/getNextSnapshot.ts | 6 +++++- packages/core/test/actorLogic.test.ts | 15 ++++++++++++--- 2 files changed, 17 insertions(+), 4 deletions(-) diff --git a/packages/core/src/getNextSnapshot.ts b/packages/core/src/getNextSnapshot.ts index 928de4b1b7..eda1071b94 100644 --- a/packages/core/src/getNextSnapshot.ts +++ b/packages/core/src/getNextSnapshot.ts @@ -22,7 +22,11 @@ export function createInertActorScope( stopChild: () => {}, system: self.system, spawnChild: (logic) => { - return createActor(logic) as any; + const child = createActor(logic) as any; + + child.start(); + + return child; } }; diff --git a/packages/core/test/actorLogic.test.ts b/packages/core/test/actorLogic.test.ts index afafd69006..e68b441122 100644 --- a/packages/core/test/actorLogic.test.ts +++ b/packages/core/test/actorLogic.test.ts @@ -338,12 +338,21 @@ describe('transition function logic (fromTransition)', () => { any, any, any - >((_state, _event, { spawnChild }) => { + >((state, _event, { spawnChild }) => { + if (state) { + return state; + } const childActor = spawnChild(fromPromise(() => Promise.resolve(42))); return childActor; }, undefined); - const actor = createActor(transitionLogic).start(); + const actor = createActor(transitionLogic); + actor.subscribe({ + error: (err) => { + console.error(err); + } + }); + actor.start(); actor.send({ type: 'anyEvent' }); expect(isActorRef(actor.getSnapshot().context)).toBeTruthy(); @@ -844,7 +853,7 @@ describe('machine logic', () => { id: 'child', src: createMachine({ context: ({ input }) => ({ - // this is only meant to showcase why we can't invoke this actor when it's missing in the persisted state + // this is meant to showcase why we can't invoke this actor when it's missing in the persisted state // because we don't have access to the right input as it depends on the event that was used to enter state `b` value: input.deep.prop }) From 8a7ea9eb5faca407890cfa597eca2915660ab6bd Mon Sep 17 00:00:00 2001 From: David Khourshid Date: Fri, 9 Feb 2024 14:10:56 -0500 Subject: [PATCH 05/10] Fix warnings --- packages/core/test/actorLogic.test.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/core/test/actorLogic.test.ts b/packages/core/test/actorLogic.test.ts index e68b441122..2e4f3fedc6 100644 --- a/packages/core/test/actorLogic.test.ts +++ b/packages/core/test/actorLogic.test.ts @@ -486,7 +486,7 @@ describe('observable logic (fromObservable)', () => { it('can spawn an actor', () => { expect.assertions(1); const observableLogic = fromObservable(({ spawnChild }) => { - const actorRef = spawnChild(fromPromise(() => Promise.resolve(42))); + const actorRef = spawnChild(fromCallback(() => {})); expect(isActorRef(actorRef)).toBe(true); return of(actorRef); }); @@ -519,7 +519,7 @@ describe('eventObservable logic (fromEventObservable)', () => { it('can spawn an actor', () => { expect.assertions(1); const observableLogic = fromObservable(({ spawnChild }) => { - const actorRef = spawnChild(fromPromise(() => Promise.resolve(42))); + const actorRef = spawnChild(fromCallback(() => {})); expect(isActorRef(actorRef)).toBe(true); return of({ type: 'a', payload: actorRef }); }); From 29279f5f9cb75f64b5a0be3fc671e07e3f40bd11 Mon Sep 17 00:00:00 2001 From: David Khourshid Date: Fri, 9 Feb 2024 16:03:08 -0500 Subject: [PATCH 06/10] Remove erroneous actor.start --- packages/core/src/getNextSnapshot.ts | 2 -- 1 file changed, 2 deletions(-) diff --git a/packages/core/src/getNextSnapshot.ts b/packages/core/src/getNextSnapshot.ts index eda1071b94..e0deb1618f 100644 --- a/packages/core/src/getNextSnapshot.ts +++ b/packages/core/src/getNextSnapshot.ts @@ -24,8 +24,6 @@ export function createInertActorScope( spawnChild: (logic) => { const child = createActor(logic) as any; - child.start(); - return child; } }; From e59e6d3810f9755a2325d15d1aaebc1bad143440 Mon Sep 17 00:00:00 2001 From: David Khourshid Date: Wed, 14 Feb 2024 15:02:08 -0500 Subject: [PATCH 07/10] Add children to promise snapshot --- packages/core/src/actors/promise.ts | 28 +++++++++++++++++++++++++-- packages/core/src/types.ts | 6 +++--- packages/core/test/actorLogic.test.ts | 16 +++++++++++++-- 3 files changed, 43 insertions(+), 7 deletions(-) diff --git a/packages/core/src/actors/promise.ts b/packages/core/src/actors/promise.ts index 10bf7642da..3211e6c1d0 100644 --- a/packages/core/src/actors/promise.ts +++ b/packages/core/src/actors/promise.ts @@ -4,16 +4,19 @@ import { ActorLogic, ActorRefFrom, ActorScope, + AnyActorRef, NonReducibleUnknown, Snapshot } from '../types.ts'; export type PromiseSnapshot = Snapshot & { input: TInput | undefined; + children: Record; }; const XSTATE_PROMISE_RESOLVE = 'xstate.promise.resolve'; const XSTATE_PROMISE_REJECT = 'xstate.promise.reject'; +const XSTATE_SPAWN_CHILD = 'xstate.spawn.child'; export type PromiseActorLogic = ActorLogic< PromiseSnapshot, @@ -120,6 +123,15 @@ export function fromPromise( status: 'stopped', input: undefined }; + case XSTATE_SPAWN_CHILD: { + return { + ...state, + children: { + ...state.children, + [(event as any).child.id]: (event as any).child + } + }; + } default: return state; } @@ -131,12 +143,23 @@ export function fromPromise( return; } + const innerSpawnChild: typeof spawnChild = (logic, actorOptions) => { + const child = spawnChild(logic, actorOptions) as AnyActorRef; + + self.send({ + type: 'child', + child + }); + + return child; + }; + const resolvedPromise = Promise.resolve( promiseCreator({ input: state.input!, system, self, - spawnChild + spawnChild: innerSpawnChild as any }) ); @@ -166,7 +189,8 @@ export function fromPromise( status: 'active', output: undefined, error: undefined, - input + input, + children: {} }; }, getPersistedSnapshot: (snapshot) => snapshot, diff --git a/packages/core/src/types.ts b/packages/core/src/types.ts index 4c37ca68a0..7a53053487 100644 --- a/packages/core/src/types.ts +++ b/packages/core/src/types.ts @@ -2153,16 +2153,16 @@ export interface ActorLogic< /** The initial setup/configuration used to create the actor logic. */ config?: unknown; /** - * Transition function that processes the current state and an incoming message + * Transition function that processes the current state and an incoming event * to produce a new state. * @param snapshot - The current state. - * @param message - The incoming message. + * @param event - The incoming event. * @param actorScope - The actor scope. * @returns The new state. */ transition: ( snapshot: TSnapshot, - message: TEvent, + event: TEvent, actorScope: ActorScope ) => TSnapshot; /** diff --git a/packages/core/test/actorLogic.test.ts b/packages/core/test/actorLogic.test.ts index 2e4f3fedc6..93ae8dd1db 100644 --- a/packages/core/test/actorLogic.test.ts +++ b/packages/core/test/actorLogic.test.ts @@ -137,6 +137,7 @@ describe('promise logic (fromPromise)', () => { expect(resolvedPersistedState).toMatchInlineSnapshot(` { + "children": {}, "error": undefined, "input": undefined, "output": 42, @@ -166,6 +167,7 @@ describe('promise logic (fromPromise)', () => { const resolvedPersistedState = actor.getPersistedSnapshot(); expect(resolvedPersistedState).toMatchInlineSnapshot(` { + "children": {}, "error": undefined, "input": undefined, "output": 1, @@ -197,6 +199,7 @@ describe('promise logic (fromPromise)', () => { const rejectedPersistedState = actorRef.getPersistedSnapshot(); expect(rejectedPersistedState).toMatchInlineSnapshot(` { + "children": {}, "error": 1, "input": undefined, "output": undefined, @@ -236,9 +239,14 @@ describe('promise logic (fromPromise)', () => { }); it('can spawn an actor', () => { - expect.assertions(1); + expect.assertions(3); const promiseLogic = fromPromise(({ spawnChild }) => { - const childActor = spawnChild(fromPromise(() => Promise.resolve(42))); + const childActor = spawnChild( + fromPromise(() => Promise.resolve(42)), + { + id: 'child' + } + ); return Promise.resolve(childActor); }); @@ -246,6 +254,9 @@ describe('promise logic (fromPromise)', () => { toPromise(actor).then((res) => { expect(isActorRef(res)).toBeTruthy(); + expect((res as AnyActorRef)._parent).toBe(actor); + + expect(actor.getSnapshot().children.child).toBe(res); }); }); }); @@ -706,6 +717,7 @@ describe('machine logic', () => { expect((persistedState as any).children.a.snapshot).toMatchInlineSnapshot(` { + "children": {}, "error": undefined, "input": undefined, "output": 42, From f6844f7ad9ada8e81c55dcb93ce7879903108583 Mon Sep 17 00:00:00 2001 From: David Khourshid Date: Sat, 24 Feb 2024 11:05:57 +0100 Subject: [PATCH 08/10] Fix tests --- packages/core/src/actors/promise.ts | 2 +- packages/core/test/inspect.test.ts | 2 ++ 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/packages/core/src/actors/promise.ts b/packages/core/src/actors/promise.ts index 3211e6c1d0..cc6bc0cabb 100644 --- a/packages/core/src/actors/promise.ts +++ b/packages/core/src/actors/promise.ts @@ -147,7 +147,7 @@ export function fromPromise( const child = spawnChild(logic, actorOptions) as AnyActorRef; self.send({ - type: 'child', + type: XSTATE_SPAWN_CHILD, child }); diff --git a/packages/core/test/inspect.test.ts b/packages/core/test/inspect.test.ts index b723366543..8c92296f82 100644 --- a/packages/core/test/inspect.test.ts +++ b/packages/core/test/inspect.test.ts @@ -327,6 +327,7 @@ describe('inspect', () => { "type": "xstate.init", }, "snapshot": { + "children": {}, "error": undefined, "input": undefined, "output": undefined, @@ -434,6 +435,7 @@ describe('inspect', () => { "type": "xstate.promise.resolve", }, "snapshot": { + "children": {}, "error": undefined, "input": undefined, "output": 42, From 831aeecc9a0a17f297dccfac4c2099be4d328d07 Mon Sep 17 00:00:00 2001 From: David Khourshid Date: Sun, 25 Feb 2024 17:12:06 +0100 Subject: [PATCH 09/10] Add more tests --- packages/core/src/actors/promise.ts | 11 ++- packages/core/test/actorLogic.test.ts | 97 +++++++++++++++++++++++++++ 2 files changed, 107 insertions(+), 1 deletion(-) diff --git a/packages/core/src/actors/promise.ts b/packages/core/src/actors/promise.ts index cc6bc0cabb..285f0381b1 100644 --- a/packages/core/src/actors/promise.ts +++ b/packages/core/src/actors/promise.ts @@ -95,13 +95,20 @@ export function fromPromise( ): PromiseActorLogic { const logic: PromiseActorLogic = { config: promiseCreator, - transition: (state, event) => { + transition: (state, event, actorScope) => { if (state.status !== 'active') { return state; } + const stopChildren = () => { + for (const child of Object.values(state.children)) { + actorScope.stopChild(child); + } + }; + switch (event.type) { case XSTATE_PROMISE_RESOLVE: { + stopChildren(); const resolvedValue = (event as any).data; return { ...state, @@ -111,6 +118,7 @@ export function fromPromise( }; } case XSTATE_PROMISE_REJECT: + stopChildren(); return { ...state, status: 'error', @@ -118,6 +126,7 @@ export function fromPromise( input: undefined }; case XSTATE_STOP: + stopChildren(); return { ...state, status: 'stopped', diff --git a/packages/core/test/actorLogic.test.ts b/packages/core/test/actorLogic.test.ts index 93ae8dd1db..e6cd3cb742 100644 --- a/packages/core/test/actorLogic.test.ts +++ b/packages/core/test/actorLogic.test.ts @@ -259,6 +259,103 @@ describe('promise logic (fromPromise)', () => { expect(actor.getSnapshot().children.child).toBe(res); }); }); + + it('stops spawned actors when it is stopped', async () => { + const promiseLogic = fromPromise(async ({ spawnChild }) => { + spawnChild( + fromPromise( + () => + new Promise((_res, _rej) => { + // ... + }) + ), + { + id: 'child' + } + ); + await new Promise((_res, _rej) => { + // ... + }); + }); + + const actor = createActor(promiseLogic).start(); + + const snapshot = await waitFor( + actor, + (s) => Object.keys(s.children).length > 0 + ); + + const child = snapshot.children.child; + + expect(isActorRef(child)).toBeTruthy(); + expect((child as AnyActorRef)._parent).toBe(actor); + + expect(actor.getSnapshot().children.child).toBe(child); + + expect(child.getSnapshot().status).toEqual('active'); + + actor.stop(); + + expect(child.getSnapshot().status).toEqual('stopped'); + }); + + it('stops spawned actors when it is done', async () => { + const promiseLogic = fromPromise(async ({ spawnChild }) => { + spawnChild( + fromPromise( + () => + new Promise((_res, _rej) => { + // ... + }) + ), + { + id: 'child' + } + ); + return 42; + }); + + const actor = createActor(promiseLogic).start(); + + await toPromise(actor); + + const snapshot = actor.getSnapshot(); + const child = snapshot.children.child; + + expect(isActorRef(child)).toBeTruthy(); + expect((child as AnyActorRef)._parent).toBe(actor); + expect(actor.getSnapshot().children.child).toBe(child); + expect(child.getSnapshot().status).toEqual('stopped'); + }); + + it('stops spawned actors when it errors', async () => { + const promiseLogic = fromPromise(async ({ spawnChild }) => { + spawnChild( + fromPromise( + () => + new Promise((_res, _rej) => { + // ... + }) + ), + { + id: 'child' + } + ); + await Promise.reject('uh oh'); + }); + + const actor = createActor(promiseLogic).start(); + + try { + await toPromise(actor); + } catch { + const snapshot = actor.getSnapshot(); + const child = snapshot.children.child; + + expect(isActorRef(child)).toBeTruthy(); + expect(child.getSnapshot().status).toEqual('stopped'); + } + }); }); describe('transition function logic (fromTransition)', () => { From 9a695175695f56856323eb671688c51558347c84 Mon Sep 17 00:00:00 2001 From: David Khourshid Date: Sun, 18 Aug 2024 17:11:01 -0400 Subject: [PATCH 10/10] Tests --- packages/core/test/actorLogic.test.ts | 68 +++-- packages/core/test/inspect.test.ts | 422 +++++++++++++------------- 2 files changed, 248 insertions(+), 242 deletions(-) diff --git a/packages/core/test/actorLogic.test.ts b/packages/core/test/actorLogic.test.ts index 053b8a879a..e1230d96e5 100644 --- a/packages/core/test/actorLogic.test.ts +++ b/packages/core/test/actorLogic.test.ts @@ -136,14 +136,15 @@ describe('promise logic (fromPromise)', () => { const resolvedPersistedState = actor.getPersistedSnapshot(); expect(resolvedPersistedState).toMatchInlineSnapshot(` - { - "children": {}, - "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 @@ -166,14 +167,15 @@ describe('promise logic (fromPromise)', () => { const resolvedPersistedState = actor.getPersistedSnapshot(); expect(resolvedPersistedState).toMatchInlineSnapshot(` - { - "children": {}, - "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, { @@ -198,14 +200,15 @@ describe('promise logic (fromPromise)', () => { const rejectedPersistedState = actorRef.getPersistedSnapshot(); expect(rejectedPersistedState).toMatchInlineSnapshot(` - { - "children": {}, - "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, { @@ -1094,14 +1097,15 @@ describe('machine logic', () => { const persistedState = actor.getPersistedSnapshot()!; expect((persistedState as any).children.a.snapshot).toMatchInlineSnapshot(` - { - "children": {}, - "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({ diff --git a/packages/core/test/inspect.test.ts b/packages/core/test/inspect.test.ts index 00fce75597..e473c961ab 100644 --- a/packages/core/test/inspect.test.ts +++ b/packages/core/test/inspect.test.ts @@ -240,216 +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": { - "children": {}, - "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": {}, - "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 () => {