Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
34 commits
Select commit Hold shift + click to select a range
fcd1bca
feat(core): add support for subscribing to system registration events
cevr Jan 13, 2024
ce1cdc7
refactor: put systemId in event, use single set for all subscriptions…
cevr Jan 14, 2024
ace2404
make event type consistent with others
cevr Jan 14, 2024
5c94a82
make implementation consistent with createActor
cevr Jan 15, 2024
d9bd505
update changeset
cevr Jan 15, 2024
69fe023
reuse subscribable type
cevr Jan 15, 2024
afb5807
make system subscribable, and use snapshot
cevr Jan 25, 2024
4315d52
initialize as empty actors first
cevr Jan 25, 2024
5894a57
Merge remote-tracking branch 'upstream/main' into cevr/system-subscribe
cevr Jan 25, 2024
2a68b5b
update changeset
cevr Jan 25, 2024
8d289c3
copy snapshot
cevr Jan 25, 2024
0cbb0c0
ensure new references
cevr Jan 25, 2024
3326fb3
allow useSelector to subscribe to actor system
cevr Jan 25, 2024
dd87510
remove log
cevr Jan 25, 2024
81cf130
update system snapshot on _set, ensure subscribers are only called on…
cevr Jan 25, 2024
61f0b77
add changeset for @xstate/react
cevr Jan 25, 2024
6840952
remove unused imports
cevr Jan 26, 2024
ae2bfee
ensure snapshot is updated for scheduled events as well
cevr Jan 26, 2024
2dff805
collapse test
cevr Jan 26, 2024
6f1dd44
add nested child state to useSelector test
cevr Jan 26, 2024
2f416a1
Merge remote-tracking branch 'upstream/main' into cevr/system-subscribe
cevr Feb 20, 2024
2f0fbd9
Merge remote-tracking branch 'upstream/main' into cevr/system-subscribe
cevr Mar 5, 2024
3fbe432
revert unintended changes
cevr Mar 5, 2024
2c12777
revert unintended changes
cevr Mar 5, 2024
98ac5ee
Merge remote-tracking branch 'upstream/main' into cevr/system-subscribe
cevr Apr 7, 2024
e7d105b
Merge remote-tracking branch 'upstream/main' into cevr/system-subscribe
cevr Apr 23, 2024
299d1da
revert unintended changes
cevr Apr 23, 2024
d98a89f
Merge remote-tracking branch 'upstream/main' into cevr/system-subscribe
cevr May 24, 2024
60d3db7
revert change
cevr May 24, 2024
8eec984
Merge remote-tracking branch 'upstream/main' into cevr/system-subscribe
cevr Jul 29, 2024
fe762fc
Merge remote-tracking branch 'upstream/main' into cevr/system-subscribe
cevr Aug 16, 2024
841d26c
Merge branch 'main' into cevr/system-subscribe
cevr Aug 26, 2024
d491b31
Merge main into cevr/system-subscribe
cevr Jan 11, 2026
cd0b633
fix: remove unused variable
cevr Jan 11, 2026
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
11 changes: 11 additions & 0 deletions .changeset/cuddly-days-grin.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
---
'@xstate/react': minor
---

`useSelector` now also allows actor.system to be passed in. This works exactly how normally passing an actor would, any changes that cause the selector to change will be automatically up to date.

ex:

```tsx
const deepChildC = useSelector(actorRef.system, (system) => system.actors.c);
```
32 changes: 32 additions & 0 deletions .changeset/tasty-baboons-dance.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
---
'xstate': minor
---

added support for `actor.system.subscribe` to subscribe to changes to the system snapshot.
`actor.system.subscribe` returns a `Subscription` object you can `.unsubscribe` to at any time.

ex:

```js
// observer object
const subscription = actor.system.subscribe({
next: (snapshot) => console.log(snapshot),
error: (err) => console.error(err),
complete: () => console.log('done')
});

// observer parameters
const subscription = actor.system.subscribe(
(snapshot) => console.log(snapshot),
(err) => console.error(err),
() => console.log('done')
);

// callback function
const subscription = actor.system.subscribe((snapshot) =>
console.log(snapshot)
);

// unsubscribe
subscription.unsubscribe();
```
5 changes: 5 additions & 0 deletions .claude/settings.local.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
{
"permissions": {
"allow": ["Bash(gh pr view:*)", "Bash(gh pr diff:*)"]
}
}
2 changes: 0 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -53,7 +53,6 @@ Special thanks to the sponsors who support this open-source project:
</picture>
</a>


## Templates

Get started by forking one of these templates on CodeSandbox:
Expand Down Expand Up @@ -551,7 +550,6 @@ actor.send({ type: 'PREVIOUS' });
</tbody>
</table>


## SemVer Policy

We understand the importance of the public contract and do not intend to release any breaking changes to the **runtime** API in a minor or patch release. We consider this with any changes we make to the XState libraries and aim to minimize their effects on existing users.
Expand Down
2 changes: 1 addition & 1 deletion packages/core/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,7 @@ export { isMachineSnapshot, type MachineSnapshot } from './State.ts';
export { StateMachine } from './StateMachine.ts';
export { StateNode } from './StateNode.ts';
export { getStateNodes } from './stateUtils.ts';
export type { ActorSystem } from './system.ts';
export type { ActorSystem, AnyActorSystem, SystemSnapshot } from './system.ts';
export { toPromise } from './toPromise.ts';
export * from './types.ts';
export {
Expand Down
126 changes: 110 additions & 16 deletions packages/core/src/system.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import {
ActorSystemInfo,
AnyActorRef,
Observer,
Subscribable,
HomomorphicOmit,
EventObject,
Subscription
Expand Down Expand Up @@ -36,6 +37,11 @@ interface Scheduler {
cancelAll(actorRef: AnyActorRef): void;
}

export interface SystemSnapshot {
_scheduledEvents: Record<ScheduledEventId, ScheduledEvent>;
actors: Record<string, AnyActorRef>;
}

type ScheduledEventId = string & { __scheduledEventId: never };

function createScheduledEventId(
Expand All @@ -45,7 +51,8 @@ function createScheduledEventId(
return `${actorRef.sessionId}.${id}` as ScheduledEventId;
}

export interface ActorSystem<T extends ActorSystemInfo> {
export interface ActorSystem<T extends ActorSystemInfo>
extends Subscribable<SystemSnapshot> {
/** @internal */
_bookId: () => string;
/** @internal */
Expand Down Expand Up @@ -73,13 +80,9 @@ export interface ActorSystem<T extends ActorSystemInfo> {
event: AnyEventObject
) => void;
scheduler: Scheduler;
getSnapshot: () => {
_scheduledEvents: Record<string, ScheduledEvent>;
};
getSnapshot: () => SystemSnapshot;
/** @internal */
_snapshot: {
_scheduledEvents: Record<ScheduledEventId, ScheduledEvent>;
};
_snapshot: SystemSnapshot;
start: () => void;
_clock: Clock;
_logger: (...args: any[]) => void;
Expand All @@ -100,6 +103,7 @@ export function createSystem<T extends ActorSystemInfo>(
const keyedActors = new Map<keyof T['actors'], AnyActorRef | undefined>();
const reverseKeyedActors = new WeakMap<AnyActorRef, keyof T['actors']>();
const inspectionObservers = new Set<Observer<InspectionEvent>>();
const systemObservers = new Set<Observer<SystemSnapshot>>();
const timerMap: { [id: ScheduledEventId]: number } = {};
const { clock, logger } = options;

Expand All @@ -120,11 +124,26 @@ export function createSystem<T extends ActorSystemInfo>(
startedAt: Date.now()
};
const scheduledEventId = createScheduledEventId(source, id);
system._snapshot._scheduledEvents[scheduledEventId] = scheduledEvent;
const snapshot = system.getSnapshot();
updateSnapshot({
_scheduledEvents: {
...snapshot._scheduledEvents,
[scheduledEventId]: scheduledEvent
},
actors: { ...snapshot.actors }
});

const timeout = clock.setTimeout(() => {
delete timerMap[scheduledEventId];
delete system._snapshot._scheduledEvents[scheduledEventId];
const {
_scheduledEvents: { [scheduledEventId]: _, ..._scheduledEvents }
} = system.getSnapshot();
updateSnapshot({
_scheduledEvents: {
..._scheduledEvents
},
actors: { ...snapshot.actors }
});

system._relay(source, target, event);
}, delay);
Expand All @@ -136,7 +155,16 @@ export function createSystem<T extends ActorSystemInfo>(
const timeout = timerMap[scheduledEventId];

delete timerMap[scheduledEventId];
delete system._snapshot._scheduledEvents[scheduledEventId];
const {
_scheduledEvents: { [scheduledEventId]: _, ..._scheduledEvents },
actors
} = system.getSnapshot();
updateSnapshot({
_scheduledEvents: {
..._scheduledEvents
},
actors: { ...actors }
});

if (timeout !== undefined) {
clock.clearTimeout(timeout);
Expand Down Expand Up @@ -167,14 +195,36 @@ export function createSystem<T extends ActorSystemInfo>(
);
};

function updateSnapshot(snapshot: SystemSnapshot) {
system._snapshot = snapshot;
systemObservers.forEach((listener) => {
listener.next?.(snapshot);
});
}

const system: ActorSystem<T> = {
_snapshot: {
_scheduledEvents:
(options?.snapshot && (options.snapshot as any).scheduler) ?? {}
(options?.snapshot && (options.snapshot as any).scheduler) ?? {},
actors: {}
},

_bookId: () => `x:${idCounter++}`,
_register: (sessionId, actorRef) => {
children.set(sessionId, actorRef);
const systemId = reverseKeyedActors.get(actorRef);
if (systemId !== undefined) {
const currentSnapshot = system.getSnapshot();
if (currentSnapshot.actors[systemId as any] !== actorRef) {
updateSnapshot({
_scheduledEvents: { ...currentSnapshot._scheduledEvents },
actors: {
...currentSnapshot.actors,
[systemId]: actorRef
}
});
}
}
return sessionId;
},
_unregister: (actorRef) => {
Expand All @@ -184,6 +234,14 @@ export function createSystem<T extends ActorSystemInfo>(
if (systemId !== undefined) {
keyedActors.delete(systemId);
reverseKeyedActors.delete(actorRef);
const {
_scheduledEvents,
actors: { [systemId]: _, ...actors }
} = system.getSnapshot();
updateSnapshot({
_scheduledEvents: { ..._scheduledEvents },
actors
});
}
},
get: (systemId) => {
Expand All @@ -192,6 +250,27 @@ export function createSystem<T extends ActorSystemInfo>(
getAll: () => {
return Object.fromEntries(keyedActors.entries()) as Partial<T['actors']>;
},
subscribe: (
nextListenerOrObserver:
| ((event: SystemSnapshot) => void)
| Observer<SystemSnapshot>,
errorListener?: (error: any) => void,
completeListener?: () => void
) => {
const observer = toObserver(
nextListenerOrObserver,
errorListener,
completeListener
);

systemObservers.add(observer);

return {
unsubscribe: () => {
systemObservers.delete(observer);
}
};
},
_set: (systemId, actorRef) => {
const existing = keyedActors.get(systemId);
if (existing && existing !== actorRef) {
Expand All @@ -202,6 +281,16 @@ export function createSystem<T extends ActorSystemInfo>(

keyedActors.set(systemId, actorRef);
reverseKeyedActors.set(actorRef, systemId);
const currentSnapshot = system.getSnapshot();
if (currentSnapshot.actors[systemId as any] !== actorRef) {
updateSnapshot({
_scheduledEvents: { ...system._snapshot._scheduledEvents },
actors: {
...system._snapshot.actors,
[systemId]: actorRef
}
});
}
},
inspect: (observerOrFn) => {
const observer = toObserver(observerOrFn);
Expand All @@ -227,15 +316,20 @@ export function createSystem<T extends ActorSystemInfo>(
scheduler,
getSnapshot: () => {
return {
_scheduledEvents: { ...system._snapshot._scheduledEvents }
_scheduledEvents: { ...system._snapshot._scheduledEvents },
actors: { ...system._snapshot.actors }
};
},

start: () => {
const scheduledEvents = system._snapshot._scheduledEvents;
system._snapshot._scheduledEvents = {};
for (const scheduledId in scheduledEvents) {
const { _scheduledEvents } = system.getSnapshot();
updateSnapshot({
_scheduledEvents: {},
actors: { ...system._snapshot.actors }
});
for (const scheduledId in _scheduledEvents) {
const { source, target, event, delay, id } =
scheduledEvents[scheduledId as ScheduledEventId];
_scheduledEvents[scheduledId as ScheduledEventId];
scheduler.schedule(source, target, event, delay, id);
}
},
Expand Down
6 changes: 4 additions & 2 deletions packages/core/src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ import type { Actor, ProcessingStatus } from './createActor.ts';
import { Guard, GuardPredicate, UnknownGuard } from './guards.ts';
import { InspectionEvent } from './inspection.ts';
import { Spawner } from './spawn.ts';
import { AnyActorSystem, Clock } from './system.ts';
import { ActorSystem, AnyActorSystem, Clock } from './system.ts';

// this is needed to make JSDoc `@link` work properly
import type { SimulatedClock } from './SimulatedClock.ts';
Expand Down Expand Up @@ -2319,7 +2319,9 @@ export type SnapshotFrom<T> =
infer _TSystem
>
? TSnapshot
: never
: R extends ActorSystem<infer _>
? ReturnType<R['getSnapshot']>
: never
: never;

export type EventFromLogic<TLogic extends AnyActorLogic> =
Expand Down
61 changes: 61 additions & 0 deletions packages/core/test/system.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -624,4 +624,65 @@ describe('system', () => {

expect(root.system.get('subchild')).toBeDefined();
});

it('should allow subscriptions to a system', () => {
const aSystemId = 'a_child';
const bSystemId = 'b_child';
const machine = createMachine({
initial: 'a',
states: {
a: {
invoke: {
src: createMachine({}),
systemId: aSystemId
},
on: {
to_b: 'b'
}
},
b: {
invoke: {
src: createMachine({}),
systemId: bSystemId
},
on: {
to_a: 'a'
}
}
}
});

const actorRef = createActor(machine).start();

const keysOverTime: string[][] = [
Object.keys(actorRef.system.getSnapshot().actors)
];
const unsubscribedKeysOverTime: string[][] = [
Object.keys(actorRef.system.getSnapshot().actors)
];

const subscription = actorRef.system.subscribe((event) => {
keysOverTime.push(Object.keys(event.actors));
});

actorRef.system.subscribe((event) => {
unsubscribedKeysOverTime.push(Object.keys(event.actors));
});

actorRef.send({ type: 'to_b' });
expect(keysOverTime).toEqual([['a_child'], [], ['b_child']]);
expect(unsubscribedKeysOverTime).toEqual([['a_child'], [], ['b_child']]);

subscription.unsubscribe();
actorRef.send({ type: 'to_a' });

expect(keysOverTime).toEqual([['a_child'], [], ['b_child']]);
expect(unsubscribedKeysOverTime).toEqual([
['a_child'],
[],
['b_child'],
[],
['a_child']
]);
});
});
1 change: 0 additions & 1 deletion packages/xstate-react/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -419,7 +419,6 @@
### Minor Changes

- [#3778](https://github.com/statelyai/xstate/pull/3778) [`f12248b23`](https://github.com/statelyai/xstate/commit/f12248b2379e4e554d69a238019216feea5211f6) Thanks [@davidkpiano](https://github.com/davidkpiano)! - The `createActorContext(...)` helper has been introduced to make global actors easier to use with React. It outputs a React Context object with the following properties:

- `.Provider` - The React Context provider
- `.useActor(...)` - A hook that can be used to get the current state and send events to the actor
- `.useSelector(...)` - A hook that can be used to select some derived state from the actor's state
Expand Down
Loading