diff --git a/.changeset/full-words-study.md b/.changeset/full-words-study.md new file mode 100644 index 0000000000..4dd3d3fe30 --- /dev/null +++ b/.changeset/full-words-study.md @@ -0,0 +1,14 @@ +--- +'xstate': minor +--- + +Make actors async iterable: + +```ts +const actor = createActor(machine); +actor.start(); + +for await (const snapshot of actor) { + console.log(snapshot); +} +``` diff --git a/packages/core/src/createActor.ts b/packages/core/src/createActor.ts index 3da242f7ab..f81b26ec74 100644 --- a/packages/core/src/createActor.ts +++ b/packages/core/src/createActor.ts @@ -464,6 +464,55 @@ export class Actor }; } + /** + * Makes the actor async iterable, allowing it to be used in `for await` + * loops. + * + * @remarks + * The async iterator yields snapshots as they are emitted by the actor. The + * iterator will complete when the actor reaches a "done" state or encounters + * an error. + * @example + * + * ```ts + * const actor = createActor(someMachine); + * actor.start(); + * + * for await (const snapshot of actor) { + * console.log('Current state:', snapshot); + * if (snapshot.status === 'done') { + * break; // Optional: break when done + * } + * } + * ``` + */ + async *[Symbol.asyncIterator](): AsyncIterator> { + // Yield the initial snapshot if the actor is already running + if (this._processingStatus === ProcessingStatus.Running) { + yield this.getSnapshot(); + } + + while (this._processingStatus !== ProcessingStatus.Stopped) { + yield await new Promise>((resolve, reject) => { + const subscription = this.subscribe({ + next: (snapshot) => { + resolve(snapshot); + subscription.unsubscribe(); + }, + error: (error) => { + reject(error); + subscription.unsubscribe(); + }, + complete: () => { + // When the actor completes, we should stop yielding + // The iterator will naturally end when the while loop condition becomes false + subscription.unsubscribe(); + } + }); + }); + } + } + public on['type'] | '*'>( type: TType, handler: ( diff --git a/packages/core/src/types.ts b/packages/core/src/types.ts index 733f92574e..4ab8e228b4 100644 --- a/packages/core/src/types.ts +++ b/packages/core/src/types.ts @@ -1964,7 +1964,8 @@ export interface ActorRef< TEvent extends EventObject, TEmitted extends EventObject = EventObject > extends Subscribable, - InteropObservable { + InteropObservable, + AsyncIterable { /** The unique identifier for this actor relative to its parent. */ id: string; sessionId: string; diff --git a/packages/core/test/actor.test.ts b/packages/core/test/actor.test.ts index 710be7735c..13d6d30d0c 100644 --- a/packages/core/test/actor.test.ts +++ b/packages/core/test/actor.test.ts @@ -1821,20 +1821,118 @@ describe('actors', () => { expect(spy).toHaveBeenCalledWith('foo'); }); - it('inline invokes should not leak into provided actors object', async () => { - const actors = {}; + it('should be async iterable', async () => { + const machine = createMachine({ + initial: 'idle', + states: { + idle: { + on: { + NEXT: 'active' + } + }, + active: { + on: { + DONE: 'done' + } + }, + done: { + type: 'final' + } + } + }); - const machine = createMachine( - { - invoke: { - src: fromPromise(async () => 'foo') + const actor = createActor(machine); + actor.start(); + + const snapshots: any[] = []; + + // Start the async iteration + const iterator = actor[Symbol.asyncIterator](); + + // Send events to trigger state changes + setTimeout(() => actor.send({ type: 'NEXT' }), 10); + setTimeout(() => actor.send({ type: 'DONE' }), 20); + + // Collect snapshots from the async iterator + for await (const snapshot of actor) { + snapshots.push(snapshot); + if (snapshot.status === 'done') { + break; + } + } + + expect(snapshots).toHaveLength(3); + expect(snapshots[0].status).toBe('active'); + expect(snapshots[0].value).toBe('idle'); + expect(snapshots[1].status).toBe('active'); + expect(snapshots[1].value).toBe('active'); + expect(snapshots[2].status).toBe('done'); + }); + + it('should handle errors in async iteration', async () => { + const machine = createMachine({ + initial: 'idle', + states: { + idle: { + on: { + ERROR: 'error' + } + }, + error: { + type: 'final' } - }, - { actors } - ); + } + }); - createActor(machine).start(); + const actor = createActor(machine); + actor.start(); + + const snapshots: any[] = []; + + setTimeout(() => actor.send({ type: 'ERROR' }), 10); + + for await (const snapshot of actor) { + snapshots.push(snapshot); + if (snapshot.status === 'done') { + break; + } + } + + expect(snapshots).toHaveLength(2); + expect(snapshots[0].status).toBe('active'); + expect(snapshots[0].value).toBe('idle'); + expect(snapshots[1].status).toBe('done'); + expect(snapshots[1].value).toBe('error'); + }); + + it('should complete iteration when actor stops', async () => { + const machine = createMachine({ + initial: 'idle', + states: { + idle: { + on: { + STOP: 'stopped' + } + }, + stopped: { + type: 'final' + } + } + }); + + const actor = createActor(machine); + actor.start(); + + const snapshots: any[] = []; + + setTimeout(() => actor.send({ type: 'STOP' }), 10); + + for await (const snapshot of actor) { + snapshots.push(snapshot); + } - expect(actors).toEqual({}); + expect(snapshots).toHaveLength(2); + expect(snapshots[0].status).toBe('active'); + expect(snapshots[1].status).toBe('done'); }); });