diff --git a/.changeset/famous-dingos-tease.md b/.changeset/famous-dingos-tease.md new file mode 100644 index 0000000000..85b580366b --- /dev/null +++ b/.changeset/famous-dingos-tease.md @@ -0,0 +1,23 @@ +--- +'xstate': minor +--- + +An actor being stopped can now be observed: + +```ts +const actor = createActor(machine); + +actor.subscribe({ + next: (snapshot) => { + if (snapshot.status === 'stopped') { + console.log('Actor stopped'); + } + } +}); + +actor.start(); + +// ... + +actor.stop(); +``` diff --git a/packages/core/src/interpreter.ts b/packages/core/src/interpreter.ts index 46728387ba..627ec2feb9 100644 --- a/packages/core/src/interpreter.ts +++ b/packages/core/src/interpreter.ts @@ -247,15 +247,19 @@ export class Actor } } + const notifyObservers = () => { + for (const observer of this.observers) { + try { + observer.next?.(snapshot); + } catch (err) { + reportUnhandledError(err); + } + } + }; + switch ((this._snapshot as any).status) { case 'active': - for (const observer of this.observers) { - try { - observer.next?.(snapshot); - } catch (err) { - reportUnhandledError(err); - } - } + notifyObservers(); break; case 'done': // next observers are meant to be notified about done snapshots @@ -264,13 +268,7 @@ export class Actor // it's more ergonomic for XState to treat a done snapshot as a "next" value // and the completion event as something that is separate, // something that merely follows emitting that done snapshot - for (const observer of this.observers) { - try { - observer.next?.(snapshot); - } catch (err) { - reportUnhandledError(err); - } - } + notifyObservers(); this._stopProcedure(); this._complete(); @@ -286,6 +284,9 @@ export class Actor case 'error': this._error((this._snapshot as any).error); break; + case 'stopped': + notifyObservers(); + break; } this.system._sendInspectionEvent({ type: '@xstate.snapshot', @@ -513,22 +514,27 @@ export class Actor this.update(nextState, event); if (event.type === XSTATE_STOP) { this._stopProcedure(); - this._complete(); } } - private _stop(): this { + private _stop(): void { if (this._processingStatus === ProcessingStatus.Stopped) { - return this; + return; } this.mailbox.clear(); if (this._processingStatus === ProcessingStatus.NotStarted) { this._processingStatus = ProcessingStatus.Stopped; - return this; + return; } this.mailbox.enqueue({ type: XSTATE_STOP } as any); - - return this; + this._processingStatus = ProcessingStatus.Stopped; + // this.update( + // { + // ...(this._snapshot as any), + // status: 'stopped' + // }, + // { event: 'xstate.stop' } as any + // ); } /** @@ -538,7 +544,8 @@ export class Actor if (this._parent) { throw new Error('A non-root actor cannot be stopped directly.'); } - return this._stop(); + this._stop(); + return this; } private _complete(): void { for (const observer of this.observers) { diff --git a/packages/core/test/interpreter.test.ts b/packages/core/test/interpreter.test.ts index 8bd2ac7528..c6ff73f061 100644 --- a/packages/core/test/interpreter.test.ts +++ b/packages/core/test/interpreter.test.ts @@ -1414,20 +1414,37 @@ describe('interpreter', () => { expect(completeCb).toHaveBeenCalledTimes(1); }); - it('should call complete() once the interpreter is stopped', () => { - const completeCb = jest.fn(); + it('should not call complete() once the actor is stopped', (done) => { + const spy = jest.fn(); - const service = createActor(createMachine({})).start(); + const actorRef = createActor(createMachine({})).start(); - service.subscribe({ - complete: () => { - completeCb(); + actorRef.subscribe({ + complete: spy, + next: (s) => { + if (s.status === 'stopped') { + done(); + } } }); - service.stop(); + actorRef.stop(); - expect(completeCb).toHaveBeenCalledTimes(1); + expect(spy).toHaveBeenCalledTimes(0); + }); + + it('stopping an actor can be observed via snapshot.status', (done) => { + const actorRef = createActor(createMachine({})).start(); + + actorRef.subscribe({ + next: (s) => { + if (s.status === 'stopped') { + done(); + } + } + }); + + actorRef.stop(); }); });