Skip to content
Open
Show file tree
Hide file tree
Changes from all 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
13 changes: 13 additions & 0 deletions .changeset/unlucky-news-drum.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
---
'xstate': minor
---

Added `onStop` handler to actors which allows registering callbacks that will be executed when an actor is stopped. This provides a way to perform cleanup or trigger side effects when an actor reaches its final state or is explicitly stopped.

```ts
const actor = createActor(someMachine);

actor.onStop(() => {
console.log('Actor stopped');
});
```
34 changes: 30 additions & 4 deletions packages/core/src/createActor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -88,6 +88,7 @@ export class Actor<TLogic extends AnyActorLogic>
string,
Set<(emittedEvent: EmittedFrom<TLogic>) => void>
> = new Map();
private stopListeners: Set<() => void> = new Set();
private logger: (...args: any[]) => void;

/** @internal */
Expand Down Expand Up @@ -339,10 +340,10 @@ export class Actor<TLogic extends AnyActorLogic>
}

/**
* Subscribe an observer to an actors snapshot values.
* Subscribe an observer to an actor's snapshot values.
*
* @remarks
* The observer will receive the actors snapshot value when it is emitted.
* The observer will receive the actor's snapshot value when it is emitted.
* The observer can be:
*
* - A plain function that receives the latest snapshot, or
Expand Down Expand Up @@ -681,6 +682,16 @@ export class Actor<TLogic extends AnyActorLogic>
this._processingStatus = ProcessingStatus.Stopped;
this.system._unregister(this);

// Execute stop listeners
for (const listener of this.stopListeners) {
try {
listener();
} catch (err) {
this._reportError(err);
}
}
this.stopListeners.clear();

return this;
}

Expand Down Expand Up @@ -754,7 +765,7 @@ export class Actor<TLogic extends AnyActorLogic>
}

/**
* Read an actors snapshot synchronously.
* Read an actor's snapshot synchronously.
*
* @remarks
* The snapshot represent an actor's last emitted value.
Expand All @@ -764,7 +775,7 @@ export class Actor<TLogic extends AnyActorLogic>
*
* Note that some actors, such as callback actors generated with
* `fromCallback`, will not emit snapshots.
* @see {@link Actor.subscribe} to subscribe to an actors snapshot values.
* @see {@link Actor.subscribe} to subscribe to an actor's snapshot values.
* @see {@link Actor.getPersistedSnapshot} to persist the internal state of an actor (which is more than just a snapshot).
*/
public getSnapshot(): SnapshotFrom<TLogic> {
Expand All @@ -775,6 +786,21 @@ export class Actor<TLogic extends AnyActorLogic>
}
return this._snapshot;
}

public onStop(callback: () => void): Subscription {
if (this._processingStatus === ProcessingStatus.Stopped) {
callback();
return {
unsubscribe: () => {}
};
}
this.stopListeners.add(callback);
return {
unsubscribe: () => {
this.stopListeners.delete(callback);
}
};
}
}

export type RequiredActorOptionsKeys<TLogic extends AnyActorLogic> =
Expand Down
7 changes: 6 additions & 1 deletion packages/core/src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1752,7 +1752,7 @@ export interface ActorOptions<TLogic extends AnyActorLogic> {
* delayed events and transitions.
*
* @remarks
* You can create your own clock. The clock interface is an object with two
* You can create your own "clock". The clock interface is an object with two
* functions/methods:
*
* - `setTimeout` - same arguments as `window.setTimeout(fn, timeout)`
Expand Down Expand Up @@ -1970,6 +1970,11 @@ export interface ActorRef<
getSnapshot: () => TSnapshot;
getPersistedSnapshot: () => Snapshot<unknown>;
stop: () => void;
/**
* Register a callback to be called when the actor is stopped. Returns a
* subscription that can be used to unsubscribe the callback.
*/
onStop: (callback: () => void) => Subscription;
toJSON?: () => any;
// TODO: figure out how to hide this externally as `sendTo(ctx => ctx.actorRef._parent._parent._parent._parent)` shouldn't be allowed
_parent?: AnyActorRef;
Expand Down
96 changes: 96 additions & 0 deletions packages/core/test/actor.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1803,3 +1803,99 @@ describe('actors', () => {
expect(actors).toEqual({});
});
});

describe('onStop', () => {
it('should call onStop callback when actor is stopped', () => {
const spy = jest.fn();
const machine = createMachine({});
const actor = createActor(machine).start();

actor.onStop(spy);
actor.stop();

expect(spy).toHaveBeenCalledTimes(1);
});

it('should call onStop callback immediately if actor is already stopped', () => {
const spy = jest.fn();
const machine = createMachine({});
const actor = createActor(machine).start();

actor.stop();
actor.onStop(spy);

expect(spy).toHaveBeenCalledTimes(1);
});

it('should not call unsubscribed onStop callbacks', () => {
const spy = jest.fn();
const machine = createMachine({});
const actor = createActor(machine).start();

const subscription = actor.onStop(spy);
subscription.unsubscribe();
actor.stop();

expect(spy).not.toHaveBeenCalled();
});

it('should call multiple onStop callbacks', () => {
const spy1 = jest.fn();
const spy2 = jest.fn();
const machine = createMachine({});
const actor = createActor(machine).start();

actor.onStop(spy1);
actor.onStop(spy2);
actor.stop();

expect(spy1).toHaveBeenCalledTimes(1);
expect(spy2).toHaveBeenCalledTimes(1);
});

it('should handle errors in onStop callbacks gracefully', () => {
const errorSpy = jest.fn();
const successSpy = jest.fn();
const machine = createMachine({});
const actor = createActor(machine).start();

actor.onStop(() => {
throw new Error('Test error');
});
actor.onStop(successSpy);
actor.subscribe({
error: errorSpy
});

actor.stop();

expect(successSpy).toHaveBeenCalledTimes(1);
expect(errorSpy).toHaveBeenCalled();
});

it('should call onStop callbacks when child actor is stopped by parent', () => {
const spy = jest.fn();
const childMachine = createMachine({});
const parentMachine = createMachine({
types: {} as {
context: { child: ActorRefFrom<typeof childMachine> };
},
context: ({ spawn }) => ({
child: spawn(childMachine)
}),
on: {
STOP_CHILD: {
actions: stopChild(({ context }) => context.child)
}
}
});

const parent = createActor(parentMachine).start();
const child = parent.getSnapshot().context.child;

child.onStop(spy);
parent.send({ type: 'STOP_CHILD' });

expect(spy).toHaveBeenCalledTimes(1);
});
});