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
30 changes: 30 additions & 0 deletions .changeset/many-teeth-cough.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
---
'xstate': minor
---

Exposes actor subscription as an inspection event.

```ts
import { createMachine, createActor } from 'xstate';

const machine = createMachine({
// ...
});

const actor = createActor(machine, {
inspect: (event) => {
// event.type === '@xstate.subscription'
// event.actorRef === actor to which the subscription belongs to
// event.subscriptionId === 'my-observer-id'
}
});

actor.subscribe(
(snapshot) => {
console.log(snapshot);
},
undefined,
undefined,
'my-observer-id'
);
```
14 changes: 11 additions & 3 deletions packages/core/src/createActor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -415,23 +415,31 @@ export class Actor<TLogic extends AnyActorLogic>
public subscribe(
nextListener?: (snapshot: SnapshotFrom<TLogic>) => void,
errorListener?: (error: any) => void,
completeListener?: () => void
completeListener?: () => void,
observerId?: string
Copy link
Collaborator

Choose a reason for hiding this comment

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

I don't quite like this addition. subscribe's signature is pseudo-standard (with Observables hopefully coming some day to the platform, see the current efforts here). I would prefer not to extend it in any custom/new way.

Copy link
Collaborator Author

Choose a reason for hiding this comment

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

Sure but how do we identify subscribers then? Any ideas as to where this id can live?

Copy link
Collaborator Author

Choose a reason for hiding this comment

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

Maybe we could pass an id as the subscription parameter?

actor.subscribe(observer, {id})

Copy link
Collaborator Author

Choose a reason for hiding this comment

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

Copy link
Collaborator

Choose a reason for hiding this comment

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

Maybe we could pass an id as the subscription parameter?

This is exactly the same thing ;p

It would be great if you could come up with a description of he exact problem you are trying to solve here and include that in the PR's description. The current description is somewhat vague. I don't really understand what subscription hierarchies are.

Copy link
Collaborator Author

@farskid farskid Oct 10, 2025

Choose a reason for hiding this comment

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

You're right. I thought I was passing it as a property of the observer.

The problem this will solve is to identify the subscribers to an actor. A tool can use the inspect protocol and map out subscribers of every actor. This tool for instance can be used to map react component tree to actor subscriptions to visualize subscriptions at scale. Helps identify unnecessary subscriptions and places to improve. This was just an example.

): Subscription;
public subscribe(
nextListenerOrObserver?:
| ((snapshot: SnapshotFrom<TLogic>) => void)
| Observer<SnapshotFrom<TLogic>>,
errorListener?: (error: any) => void,
completeListener?: () => void
completeListener?: () => void,
observerId?: string
): Subscription {
const observer = toObserver(
nextListenerOrObserver,
errorListener,
completeListener
completeListener,
observerId
);

if (this._processingStatus !== ProcessingStatus.Stopped) {
this.observers.add(observer);
this._actorScope.system._sendInspectionEvent({
type: '@xstate.subscription',
actorRef: this,
subscriptionId: observer.id
});
} else {
switch ((this._snapshot as any).status) {
case 'done':
Expand Down
15 changes: 12 additions & 3 deletions packages/core/src/inspection.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,18 +10,21 @@ export type InspectionEvent =
| InspectedEventEvent
| InspectedActorEvent
| InspectedMicrostepEvent
| InspectedActionEvent;
| InspectedActionEvent
| InspectedSubscriptionEvent;

interface BaseInspectionEventProperties {
interface BaseInspectionEventProperties<T extends ActorRefLike = ActorRefLike> {
rootId: string; // the session ID of the root
/**
* The relevant actorRef for the inspection event.
*
* - For snapshot events, this is the `actorRef` of the snapshot.
* - For event events, this is the target `actorRef` (recipient of event).
* - For actor events, this is the `actorRef` of the registered actor.
* - For subscription events, this is the `actorRef` of the actor that is being
* subscribed to.
*/
actorRef: ActorRefLike;
actorRef: T;
}

export interface InspectedSnapshotEvent extends BaseInspectionEventProperties {
Expand Down Expand Up @@ -54,6 +57,12 @@ export interface InspectedEventEvent extends BaseInspectionEventProperties {
event: AnyEventObject; // { type: string, ... }
}

export interface InspectedSubscriptionEvent
extends BaseInspectionEventProperties<ActorRefLike & { systemId?: string }> {
type: '@xstate.subscription';
subscriptionId: string | undefined;
}

export interface InspectedActorEvent extends BaseInspectionEventProperties {
type: '@xstate.actor';
}
1 change: 1 addition & 0 deletions packages/core/src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1905,6 +1905,7 @@ export type Observer<T> = {
next?: (value: T) => void;
error?: (err: unknown) => void;
complete?: () => void;
id?: string;
};

export interface Subscription {
Expand Down
6 changes: 4 additions & 2 deletions packages/core/src/utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -234,7 +234,8 @@ export function normalizeTarget<
export function toObserver<T>(
nextHandler?: Observer<T> | ((value: T) => void),
errorHandler?: (error: any) => void,
completionHandler?: () => void
completionHandler?: () => void,
observerId?: string
): Observer<T> {
const isObserver = typeof nextHandler === 'object';
const self = isObserver ? nextHandler : undefined;
Expand All @@ -244,7 +245,8 @@ export function toObserver<T>(
error: (isObserver ? nextHandler.error : errorHandler)?.bind(self),
complete: (isObserver ? nextHandler.complete : completionHandler)?.bind(
self
)
),
id: observerId
};
}

Expand Down
62 changes: 61 additions & 1 deletion packages/core/test/inspect.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,10 @@ import {
raise,
setup
} from '../src';
import { InspectedActionEvent } from '../src/inspection';
import {
InspectedActionEvent,
InspectedSubscriptionEvent
} from '../src/inspection';

function simplifyEvents(
inspectionEvents: InspectionEvent[],
Expand Down Expand Up @@ -65,6 +68,14 @@ function simplifyEvents(
action: inspectionEvent.action
};
}

if (inspectionEvent.type === '@xstate.subscription') {
return {
type: inspectionEvent.type,
actorSystemId: inspectionEvent.actorRef?.systemId,
subscriptionId: inspectionEvent.subscriptionId
};
}
});
}

Expand Down Expand Up @@ -1044,6 +1055,55 @@ describe('inspect', () => {
`);
});

it('should inspect subscriptions', () => {
const events: InspectedSubscriptionEvent[] = [];

const machine = setup({}).createMachine({});

const actor = createActor(machine, {
inspect: (ev) => {
if (ev.type === '@xstate.subscription') {
events.push(ev);
}
},
systemId: 'actor1' // making sure that the systemId is included in the event
});

actor.start();
actor.subscribe(
(snapshot) => {
console.log('sub1', snapshot);
},
undefined,
undefined,
'sub1'
);
actor.subscribe(
(snapshot) => {
console.log('sub2', snapshot);
},
undefined,
undefined,
'sub2'
);

expect(simplifyEvents(events, (ev) => ev.type === '@xstate.subscription'))
.toMatchInlineSnapshot(`
[
{
"actorSystemId": "actor1",
"subscriptionId": "sub1",
"type": "@xstate.subscription",
},
{
"actorSystemId": "actor1",
"subscriptionId": "sub2",
"type": "@xstate.subscription",
},
]
`);
});

it('@xstate.microstep inspection events should report no transitions if an unknown event was sent', () => {
const machine = createMachine({});
expect.assertions(1);
Expand Down