From 2ac08b70054e8c6699051b7fafa450af95f7e483 Mon Sep 17 00:00:00 2001 From: David Khourshid Date: Sat, 22 Jun 2024 08:43:16 +0200 Subject: [PATCH] [core] Add emit support for all actor logic creators (#4935) * Add emit support for all actor logic creators * Add test for restored root actor * Update .changeset/smooth-crabs-dress.md Co-authored-by: with-heart --------- Co-authored-by: with-heart --- .changeset/smooth-crabs-dress.md | 60 ++++++ packages/core/src/actors/callback.ts | 26 ++- packages/core/src/actors/observable.ts | 49 +++-- packages/core/src/actors/promise.ts | 29 ++- packages/core/src/actors/transition.ts | 7 +- packages/core/test/emit.test.ts | 252 +++++++++++++++++++++++++ 6 files changed, 386 insertions(+), 37 deletions(-) create mode 100644 .changeset/smooth-crabs-dress.md diff --git a/.changeset/smooth-crabs-dress.md b/.changeset/smooth-crabs-dress.md new file mode 100644 index 0000000000..84d937ae26 --- /dev/null +++ b/.changeset/smooth-crabs-dress.md @@ -0,0 +1,60 @@ +--- +'xstate': minor +--- + +All actor logic creators now support [emitting events](https://stately.ai/docs/event-emitter): + +**Promise actors** + +```ts +const logic = fromPromise(async ({ emit }) => { + // ... + emit({ + type: 'emitted', + msg: 'hello' + }); + // ... +}); +``` + +**Transition actors** + +```ts +const logic = fromTransition((state, event, { emit }) => { + // ... + emit({ + type: 'emitted', + msg: 'hello' + }); + // ... + return state; +}, {}); +``` + +**Observable actors** + +```ts +const logic = fromObservable(({ emit }) => { + // ... + + emit({ + type: 'emitted', + msg: 'hello' + }); + + // ... +}); +``` + +**Callback actors** + +```ts +const logic = fromCallback(({ emit }) => { + // ... + emit({ + type: 'emitted', + msg: 'hello' + }); + // ... +}); +``` diff --git a/packages/core/src/actors/callback.ts b/packages/core/src/actors/callback.ts index c8ed9df29d..536b5d312b 100644 --- a/packages/core/src/actors/callback.ts +++ b/packages/core/src/actors/callback.ts @@ -26,13 +26,14 @@ export type CallbackSnapshot = Snapshot & { export type CallbackActorLogic< TEvent extends EventObject, - TInput = NonReducibleUnknown + TInput = NonReducibleUnknown, + TEmitted extends EventObject = EventObject > = ActorLogic< CallbackSnapshot, TEvent, TInput, AnyActorSystem, - EventObject // TEmitted + TEmitted >; export type CallbackActorRef< @@ -49,13 +50,15 @@ export type Receiver = ( export type InvokeCallback< TEvent extends EventObject = AnyEventObject, TSentEvent extends EventObject = AnyEventObject, - TInput = NonReducibleUnknown + TInput = NonReducibleUnknown, + TEmitted extends EventObject = EventObject > = ({ input, system, self, sendBack, - receive + receive, + emit }: { /** * Data that was provided to the callback actor @@ -79,6 +82,7 @@ export type InvokeCallback< * the listener is then called whenever events are received by the callback actor */ receive: Receiver; + emit: (emitted: TEmitted) => void; }) => (() => void) | void; /** @@ -140,14 +144,15 @@ export type InvokeCallback< */ export function fromCallback< TEvent extends EventObject, - TInput = NonReducibleUnknown + TInput = NonReducibleUnknown, + TEmitted extends EventObject = EventObject >( - invokeCallback: InvokeCallback -): CallbackActorLogic { - const logic: CallbackActorLogic = { + invokeCallback: InvokeCallback +): CallbackActorLogic { + const logic: CallbackActorLogic = { config: invokeCallback, start: (state, actorScope) => { - const { self, system } = actorScope; + const { self, system, emit } = actorScope; const callbackState: CallbackInstanceState = { receivers: undefined, @@ -171,7 +176,8 @@ export function fromCallback< receive: (listener) => { callbackState.receivers ??= new Set(); callbackState.receivers.add(listener); - } + }, + emit }); }, transition: (state, event, actorScope) => { diff --git a/packages/core/src/actors/observable.ts b/packages/core/src/actors/observable.ts index 6fddbe8aa6..6a2c197d89 100644 --- a/packages/core/src/actors/observable.ts +++ b/packages/core/src/actors/observable.ts @@ -25,13 +25,14 @@ export type ObservableSnapshot< export type ObservableActorLogic< TContext, - TInput extends NonReducibleUnknown + TInput extends NonReducibleUnknown, + TEmitted extends EventObject = EventObject > = ActorLogic< ObservableSnapshot, { type: string; [k: string]: unknown }, TInput, AnyActorSystem, - EventObject // TEmitted + TEmitted >; export type ObservableActorRef = ActorRefFrom< @@ -78,20 +79,26 @@ export type ObservableActorRef = ActorRefFrom< * @see {@link https://rxjs.dev} for documentation on RxJS Observable and observable creators. * @see {@link Subscribable} interface in XState, which is based on and compatible with RxJS Observable. */ -export function fromObservable( +export function fromObservable< + TContext, + TInput extends NonReducibleUnknown, + TEmitted extends EventObject = EventObject +>( observableCreator: ({ input, - system + system, + self }: { input: TInput; system: AnyActorSystem; self: ObservableActorRef; + emit: (emitted: TEmitted) => void; }) => Subscribable -): ObservableActorLogic { +): ObservableActorLogic { // TODO: add event types - const logic: ObservableActorLogic = { + const logic: ObservableActorLogic = { config: observableCreator, - transition: (snapshot, event, { self, id, defer, system }) => { + transition: (snapshot, event) => { if (snapshot.status !== 'active') { return snapshot; } @@ -141,7 +148,7 @@ export function fromObservable( _subscription: undefined }; }, - start: (state, { self, system }) => { + start: (state, { self, system, emit }) => { if (state.status === 'done') { // Do not restart a completed observable return; @@ -149,7 +156,8 @@ export function fromObservable( state._subscription = observableCreator({ input: state.input!, system, - self + self, + emit }).subscribe({ next: (value) => { system._relay(self, self, { @@ -223,20 +231,24 @@ export function fromObservable( * ``` */ export function fromEventObservable< - T extends EventObject, - TInput extends NonReducibleUnknown + TEvent extends EventObject, + TInput extends NonReducibleUnknown, + TEmitted extends EventObject = EventObject >( lazyObservable: ({ input, - system + system, + self, + emit }: { input: TInput; system: AnyActorSystem; - self: ObservableActorRef; - }) => Subscribable -): ObservableActorLogic { + self: ObservableActorRef; + emit: (emitted: TEmitted) => void; + }) => Subscribable +): ObservableActorLogic { // TODO: event types - const logic: ObservableActorLogic = { + const logic: ObservableActorLogic = { config: lazyObservable, transition: (state, event) => { if (state.status !== 'active') { @@ -281,7 +293,7 @@ export function fromEventObservable< _subscription: undefined }; }, - start: (state, { self, system }) => { + start: (state, { self, system, emit }) => { if (state.status === 'done') { // Do not restart a completed observable return; @@ -290,7 +302,8 @@ export function fromEventObservable< state._subscription = lazyObservable({ input: state.input!, system, - self + self, + emit }).subscribe({ next: (value) => { if (self._parent) { diff --git a/packages/core/src/actors/promise.ts b/packages/core/src/actors/promise.ts index a1fc89502f..6e0ff0c8eb 100644 --- a/packages/core/src/actors/promise.ts +++ b/packages/core/src/actors/promise.ts @@ -16,12 +16,16 @@ export type PromiseSnapshot = Snapshot & { const XSTATE_PROMISE_RESOLVE = 'xstate.promise.resolve'; const XSTATE_PROMISE_REJECT = 'xstate.promise.reject'; -export type PromiseActorLogic = ActorLogic< +export type PromiseActorLogic< + TOutput, + TInput = unknown, + TEmitted extends EventObject = EventObject +> = ActorLogic< PromiseSnapshot, { type: string; [k: string]: unknown }, TInput, // input AnyActorSystem, - EventObject // TEmitted + TEmitted // TEmitted >; export type PromiseActorRef = ActorRefFrom< @@ -75,10 +79,17 @@ export type PromiseActorRef = ActorRefFrom< const controllerMap = new WeakMap(); -export function fromPromise( +export function fromPromise< + TOutput, + TInput = NonReducibleUnknown, + TEmitted extends EventObject = EventObject +>( promiseCreator: ({ input, - system + system, + self, + signal, + emit }: { /** * Data that was provided to the promise actor @@ -93,9 +104,10 @@ export function fromPromise( */ self: PromiseActorRef; signal: AbortSignal; + emit: (emitted: TEmitted) => void; }) => PromiseLike -): PromiseActorLogic { - const logic: PromiseActorLogic = { +): PromiseActorLogic { + const logic: PromiseActorLogic = { config: promiseCreator, transition: (state, event, scope) => { if (state.status !== 'active') { @@ -131,7 +143,7 @@ export function fromPromise( return state; } }, - start: (state, { self, system }) => { + start: (state, { self, system, emit }) => { // TODO: determine how to allow customizing this so that promises // can be restarted if necessary if (state.status !== 'active') { @@ -144,7 +156,8 @@ export function fromPromise( input: state.input!, system, self, - signal: controller.signal + signal: controller.signal, + emit }) ); diff --git a/packages/core/src/actors/transition.ts b/packages/core/src/actors/transition.ts index c474c95a57..c29ce1ceeb 100644 --- a/packages/core/src/actors/transition.ts +++ b/packages/core/src/actors/transition.ts @@ -100,7 +100,12 @@ export function fromTransition< transition: ( snapshot: TContext, event: TEvent, - actorScope: ActorScope, TEvent, TSystem> + actorScope: ActorScope< + TransitionSnapshot, + TEvent, + TSystem, + TEmitted + > ) => TContext, initialContext: | TContext diff --git a/packages/core/test/emit.test.ts b/packages/core/test/emit.test.ts index 8cfa9d2f34..5fb2365a6a 100644 --- a/packages/core/test/emit.test.ts +++ b/packages/core/test/emit.test.ts @@ -3,6 +3,11 @@ import { createActor, createMachine, enqueueActions, + fromCallback, + fromEventObservable, + fromObservable, + fromPromise, + fromTransition, setup } from '../src'; import { emit } from '../src/actions/emit'; @@ -221,4 +226,251 @@ describe('event emitter', () => { expect(spy).toHaveBeenCalledTimes(1); }); + + it('events can be emitted from promise logic', () => { + const spy = jest.fn(); + + const logic = fromPromise( + async ({ emit }) => { + emit({ + type: 'emitted', + msg: 'hello' + }); + } + ); + + const actor = createActor(logic); + + actor.on('emitted', (ev) => { + ev.type satisfies 'emitted'; + + // @ts-expect-error + ev.type satisfies 'whatever'; + + ev satisfies { msg: string }; + + spy(ev); + }); + + actor.start(); + + expect(spy).toHaveBeenCalledWith( + expect.objectContaining({ + type: 'emitted', + msg: 'hello' + }) + ); + }); + + it('events can be emitted from transition logic', () => { + const spy = jest.fn(); + + const logic = fromTransition< + any, + any, + any, + any, + { type: 'emitted'; msg: string } + >((s, e, { emit }) => { + if (e.type === 'emit') { + emit({ + type: 'emitted', + msg: 'hello' + }); + } + return s; + }, {}); + + const actor = createActor(logic); + + actor.on('emitted', (ev) => { + ev.type satisfies 'emitted'; + + // @ts-expect-error + ev.type satisfies 'whatever'; + + ev satisfies { msg: string }; + + spy(ev); + }); + + actor.start(); + + actor.send({ type: 'emit' }); + + expect(spy).toHaveBeenCalledWith( + expect.objectContaining({ + type: 'emitted', + msg: 'hello' + }) + ); + }); + + it('events can be emitted from observable logic', () => { + const spy = jest.fn(); + + const logic = fromObservable( + ({ emit }) => { + emit({ + type: 'emitted', + msg: 'hello' + }); + + return { + subscribe: () => { + return { + unsubscribe: () => {} + }; + } + }; + } + ); + + const actor = createActor(logic); + + actor.on('emitted', (ev) => { + ev.type satisfies 'emitted'; + + // @ts-expect-error + ev.type satisfies 'whatever'; + + ev satisfies { msg: string }; + + spy(ev); + }); + + actor.start(); + + expect(spy).toHaveBeenCalledWith( + expect.objectContaining({ + type: 'emitted', + msg: 'hello' + }) + ); + }); + + it('events can be emitted from event observable logic', () => { + const spy = jest.fn(); + + const logic = fromEventObservable< + any, + any, + { type: 'emitted'; msg: string } + >(({ emit }) => { + emit({ + type: 'emitted', + msg: 'hello' + }); + + return { + subscribe: () => { + return { + unsubscribe: () => {} + }; + } + }; + }); + + const actor = createActor(logic); + + actor.on('emitted', (ev) => { + ev.type satisfies 'emitted'; + + // @ts-expect-error + ev.type satisfies 'whatever'; + + ev satisfies { msg: string }; + + spy(ev); + }); + + actor.start(); + + expect(spy).toHaveBeenCalledWith( + expect.objectContaining({ + type: 'emitted', + msg: 'hello' + }) + ); + }); + + it('events can be emitted from callback logic', () => { + const spy = jest.fn(); + + const logic = fromCallback( + ({ emit }) => { + emit({ + type: 'emitted', + msg: 'hello' + }); + } + ); + + const actor = createActor(logic); + + actor.on('emitted', (ev) => { + ev.type satisfies 'emitted'; + + // @ts-expect-error + ev.type satisfies 'whatever'; + + ev satisfies { msg: string }; + + spy(ev); + }); + + actor.start(); + + expect(spy).toHaveBeenCalledWith( + expect.objectContaining({ + type: 'emitted', + msg: 'hello' + }) + ); + }); + + it('events can be emitted from callback logic (restored root)', () => { + const spy = jest.fn(); + + const logic = fromCallback( + ({ emit }) => { + emit({ + type: 'emitted', + msg: 'hello' + }); + } + ); + + const machine = setup({ + actors: { logic } + }).createMachine({ + invoke: { + id: 'cb', + src: 'logic' + } + }); + + const actor = createActor(machine); + + // Persist the root actor + const persistedSnapshot = actor.getPersistedSnapshot(); + + // Rehydrate a new instance of the root actor using the persisted snapshot + const restoredActor = createActor(machine, { + snapshot: persistedSnapshot + }); + + restoredActor.getSnapshot().children.cb!.on('emitted', (ev) => { + spy(ev); + }); + + restoredActor.start(); + + expect(spy).toHaveBeenCalledWith( + expect.objectContaining({ + type: 'emitted', + msg: 'hello' + }) + ); + }); });