Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add ability to spawn actor from any logic #4724

Open
wants to merge 18 commits into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from 6 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 6 additions & 2 deletions packages/core/src/actors/callback.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import { AnyActorSystem } from '../system.ts';
import {
ActorLogic,
ActorRefFrom,
ActorScope,
AnyActorRef,
AnyEventObject,
EventObject,
Expand Down Expand Up @@ -49,7 +50,8 @@ export type InvokeCallback<
system,
self,
sendBack,
receive
receive,
spawn
}: {
/**
* Data that was provided to the callback actor
Expand All @@ -73,6 +75,7 @@ export type InvokeCallback<
* the listener is then called whenever events are received by the callback actor
*/
receive: Receiver<TEvent>;
spawn: ActorScope<any, any>['spawnChild'];
Copy link
Member

Choose a reason for hiding this comment

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

shouldn't all of those actor logics receive stopChild as well?

}) => (() => void) | void;

/**
Expand Down Expand Up @@ -165,7 +168,8 @@ export function fromCallback<
receive: (listener) => {
callbackState.receivers ??= new Set();
callbackState.receivers.add(listener);
}
},
spawn: actorScope.spawnChild
});
},
transition: (state, event, actorScope) => {
Expand Down
7 changes: 5 additions & 2 deletions packages/core/src/actors/observable.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import { AnyActorSystem } from '../system.ts';
import {
ActorLogic,
ActorRefFrom,
ActorScope,
EventObject,
NonReducibleUnknown,
Snapshot,
Expand Down Expand Up @@ -85,6 +86,7 @@ export function fromObservable<TContext, TInput extends NonReducibleUnknown>(
input: TInput;
system: AnyActorSystem;
self: ObservableActorRef<TContext>;
spawnChild: ActorScope<any, any>['spawnChild'];
}) => Subscribable<TContext>
): ObservableActorLogic<TContext, TInput> {
// TODO: add event types
Expand Down Expand Up @@ -140,15 +142,16 @@ export function fromObservable<TContext, TInput extends NonReducibleUnknown>(
_subscription: undefined
};
},
start: (state, { self, system }) => {
start: (state, { self, system, spawnChild }) => {
if (state.status === 'done') {
// Do not restart a completed observable
return;
}
state._subscription = observableCreator({
input: state.input!,
system,
self
self,
spawnChild
}).subscribe({
next: (value) => {
system._relay(self, self, {
Expand Down
11 changes: 9 additions & 2 deletions packages/core/src/actors/promise.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import { AnyActorSystem } from '../system.ts';
import {
ActorLogic,
ActorRefFrom,
ActorScope,
NonReducibleUnknown,
Snapshot
} from '../types.ts';
Expand Down Expand Up @@ -86,6 +87,7 @@ export function fromPromise<TOutput, TInput = NonReducibleUnknown>(
* The parent actor of the promise actor
*/
self: PromiseActorRef<TOutput>;
spawnChild: ActorScope<any, any, any>['spawnChild'];
}) => PromiseLike<TOutput>
): PromiseActorLogic<TOutput, TInput> {
const logic: PromiseActorLogic<TOutput, TInput> = {
Expand Down Expand Up @@ -122,15 +124,20 @@ export function fromPromise<TOutput, TInput = NonReducibleUnknown>(
return state;
}
},
start: (state, { self, system }) => {
start: (state, { self, system, spawnChild }) => {
// TODO: determine how to allow customizing this so that promises
// can be restarted if necessary
if (state.status !== 'active') {
return;
}

const resolvedPromise = Promise.resolve(
promiseCreator({ input: state.input!, system, self })
promiseCreator({
input: state.input!,
system,
self,
spawnChild
})
);

resolvedPromise.then(
Expand Down
8 changes: 5 additions & 3 deletions packages/core/src/actors/transition.ts
Original file line number Diff line number Diff line change
Expand Up @@ -98,10 +98,12 @@ export function fromTransition<
| TContext
| (({
input,
self
self,
spawnChild
}: {
input: TInput;
self: TransitionActorRef<TContext, TEvent>;
spawnChild: ActorScope<any, any, any>['spawnChild'];
}) => TContext) // TODO: type
): TransitionActorLogic<TContext, TEvent, TInput> {
return {
Expand All @@ -116,14 +118,14 @@ export function fromTransition<
)
};
},
getInitialSnapshot: (_, input) => {
getInitialSnapshot: ({ self, spawnChild }, input) => {
return {
status: 'active',
output: undefined,
error: undefined,
context:
typeof initialContext === 'function'
? (initialContext as any)({ input })
? (initialContext as any)({ input, self, spawnChild })
: initialContext
};
},
Expand Down
16 changes: 16 additions & 0 deletions packages/core/src/createActor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import { symbolObservable } from './symbolObservable.ts';
import { AnyActorSystem, Clock, createSystem } from './system.ts';

import type {
ActorRefFrom,
ActorScope,
AnyActorLogic,
ConditionalRequired,
Expand Down Expand Up @@ -178,6 +179,21 @@ export class Actor<TLogic extends AnyActorLogic>
);
}
(child as any)._stop();
},
spawnChild: <T extends AnyActorLogic>(
logic: T,
actorOptions?: ActorOptions<T>
Copy link
Member

Choose a reason for hiding this comment

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

this should reuse the type tricks from here:

...[options]: ConditionalRequired<
[
options?: ActorOptions<TLogic> & {
[K in RequiredOptions<TLogic>]: unknown;
}
],
IsNotNever<RequiredOptions<TLogic>>
>

) => {
const actor = createActor(logic, {
parent: this,
...actorOptions
});

if (this._processingStatus === ProcessingStatus.Running) {
actor.start();
}
Comment on lines +190 to +192
Copy link
Member

Choose a reason for hiding this comment

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

what about the actors spawned when this._processingStatus === ProcessingStatus.NotStarted?


return actor as ActorRefFrom<T>;
}
};

Expand Down
9 changes: 8 additions & 1 deletion packages/core/src/getNextSnapshot.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,14 @@ export function createInertActorScope<T extends AnyActorLogic>(
logger: () => {},
sessionId: '',
stopChild: () => {},
system: self.system
system: self.system,
spawnChild: (logic) => {
const child = createActor(logic) as any;

child.start();
davidkpiano marked this conversation as resolved.
Show resolved Hide resolved

return child;
}
};

return inertActorScope;
Expand Down
4 changes: 4 additions & 0 deletions packages/core/src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2106,6 +2106,10 @@ export interface ActorScope<
defer: (fn: () => void) => void;
system: TSystem;
stopChild: (child: AnyActorRef) => void;
spawnChild: <T extends AnyActorLogic>(
Copy link
Member

Choose a reason for hiding this comment

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

logic: T,
actorOptions?: ActorOptions<T>
) => ActorRefFrom<T>;
}

export type AnyActorScope = ActorScope<any, any, AnyActorSystem>;
Expand Down
9 changes: 9 additions & 0 deletions packages/core/src/utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -206,6 +206,15 @@ export function isActorLogic(value: any): value is ActorLogic<any, any> {
);
}

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);
}
Expand Down
102 changes: 100 additions & 2 deletions packages/core/test/actorLogic.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,8 @@ import {
createActor,
AnyActorLogic,
Snapshot,
ActorLogic
ActorLogic,
toPromise
} from '../src/index.ts';
import {
fromCallback,
Expand All @@ -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 () => {
Expand Down Expand Up @@ -232,6 +234,20 @@ describe('promise logic (fromPromise)', () => {

createActor(promiseLogic).start();
});

it('can spawn an actor', () => {
expect.assertions(1);
const promiseLogic = fromPromise<AnyActorRef>(({ spawnChild }) => {
const childActor = spawnChild(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)', () => {
Expand Down Expand Up @@ -314,6 +330,56 @@ 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, { spawnChild }) => {
if (state) {
return state;
}
const childActor = spawnChild(fromPromise(() => Promise.resolve(42)));
return childActor;
}, undefined);

const actor = createActor(transitionLogic);
actor.subscribe({
error: (err) => {
console.error(err);
}
});
actor.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;
},
({ spawnChild }) => {
const childActor = spawnChild(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)', () => {
Expand Down Expand Up @@ -416,6 +482,17 @@ describe('observable logic (fromObservable)', () => {

createActor(observableLogic).start();
});

it('can spawn an actor', () => {
expect.assertions(1);
const observableLogic = fromObservable(({ spawnChild }) => {
const actorRef = spawnChild(fromCallback(() => {}));
expect(isActorRef(actorRef)).toBe(true);
return of(actorRef);
});

createActor(observableLogic).start();
});
});

describe('eventObservable logic (fromEventObservable)', () => {
Expand All @@ -438,6 +515,17 @@ describe('eventObservable logic (fromEventObservable)', () => {

createActor(observableLogic).start();
});

it('can spawn an actor', () => {
expect.assertions(1);
const observableLogic = fromObservable(({ spawnChild }) => {
const actorRef = spawnChild(fromCallback(() => {}));
expect(isActorRef(actorRef)).toBe(true);
return of({ type: 'a', payload: actorRef });
});

createActor(observableLogic).start();
});
});

describe('callback logic (fromCallback)', () => {
Expand Down Expand Up @@ -556,6 +644,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', () => {
Expand Down Expand Up @@ -755,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
})
Expand Down
3 changes: 2 additions & 1 deletion packages/xstate-graph/src/actorScope.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
};
}
Loading