Skip to content

Commit

Permalink
Merge pull request #2197 from davidkpiano/davidkpiano/getsnapshot
Browse files Browse the repository at this point in the history
[core] Add `.getSnapshot()` to actor refs
  • Loading branch information
davidkpiano committed May 20, 2021
2 parents 560618f + 1432ae7 commit a4256dc
Show file tree
Hide file tree
Showing 6 changed files with 112 additions and 39 deletions.
32 changes: 32 additions & 0 deletions .changeset/neat-boxes-float.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
---
'xstate': minor
---

All spawned and invoked actors now have a `.getSnapshot()` method, which allows you to retrieve the latest value emitted from that actor. That value may be `undefined` if no value has been emitted yet.

```js
const machine = createMachine({
context: {
promiseRef: null
},
initial: 'pending',
states: {
pending: {
entry: assign({
promiseRef: () => spawn(fetch(/* ... */), 'some-promise')
})
}
}
});

const service = interpret(machine)
.onTransition((state) => {
// Read promise value synchronously
const resolvedValue = state.context.promiseRef?.getSnapshot();
// => undefined (if promise not resolved yet)
// => { ... } (resolved data)
})
.start();

// ...
```
11 changes: 8 additions & 3 deletions packages/core/src/Actor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,13 +26,14 @@ export interface Actor<
deferred?: boolean;
}

export function createNullActor(id: string): Actor {
export function createNullActor(id: string): SpawnedActorRef<any> {
return {
id,
send: () => void 0,
subscribe: () => ({
unsubscribe: () => void 0
}),
getSnapshot: () => undefined,
toJSON: () => ({
id
})
Expand All @@ -50,7 +51,7 @@ export function createInvocableActor<TC, TE extends EventObject>(
machine: StateMachine<TC, any, TE, any>,
context: TC,
_event: SCXML.Event<TE>
): Actor {
): SpawnedActorRef<any> {
const invokeSrc = toInvokeSource(invokeDefinition.src);
const serviceCreator = machine?.options.services?.[invokeSrc.type];
const resolvedData = invokeDefinition.data
Expand All @@ -64,6 +65,7 @@ export function createInvocableActor<TC, TE extends EventObject>(
)
: createNullActor(invokeDefinition.id);

// @ts-ignore
tempActor.meta = invokeDefinition;

return tempActor;
Expand All @@ -73,12 +75,15 @@ export function createDeferredActor(
entity: Spawnable,
id: string,
data?: any
): Actor {
): SpawnedActorRef<any, undefined> {
const tempActor = createNullActor(id);

// @ts-ignore
tempActor.deferred = true;

if (isMachine(entity)) {
// "mute" the existing service scope so potential spawned actors within the `.initialState` stay deferred here
// @ts-ignore
tempActor.state = serviceScope.provide(
undefined,
() => (data ? entity.withContext(data) : entity).initialState
Expand Down
23 changes: 20 additions & 3 deletions packages/core/src/interpreter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -113,7 +113,8 @@ export class Interpreter<
TStateSchema extends StateSchema = any,
TEvent extends EventObject = EventObject,
TTypestate extends Typestate<TContext> = { value: any; context: TContext }
> implements Actor<State<TContext, TEvent, TStateSchema, TTypestate>, TEvent> {
> implements
SpawnedActorRef<TEvent, State<TContext, TEvent, TStateSchema, TTypestate>> {
/**
* The default interpreter options:
*
Expand Down Expand Up @@ -1012,10 +1013,12 @@ export class Interpreter<
id: string
): SpawnedActorRef<never, T> {
let canceled = false;
let resolvedData: T | undefined = undefined;

promise.then(
(response) => {
if (!canceled) {
resolvedData = response;
this.removeChild(id);
this.send(
toSCXMLEvent(doneInvoke(id, response) as any, { origin: id })
Expand Down Expand Up @@ -1081,7 +1084,8 @@ export class Interpreter<
},
toJSON() {
return { id };
}
},
getSnapshot: () => resolvedData
};

this.children.set(id, actor);
Expand All @@ -1095,8 +1099,10 @@ export class Interpreter<
let canceled = false;
const receivers = new Set<(e: EventObject) => void>();
const listeners = new Set<(e: EventObject) => void>();
let emitted: TEvent | undefined = undefined;

const receive = (e: TEvent) => {
emitted = e;
listeners.forEach((listener) => listener(e));
if (canceled) {
return;
Expand Down Expand Up @@ -1140,7 +1146,8 @@ export class Interpreter<
},
toJSON() {
return { id };
}
},
getSnapshot: () => emitted
};

this.children.set(id, actor);
Expand All @@ -1151,8 +1158,11 @@ export class Interpreter<
source: Subscribable<T>,
id: string
): SpawnedActorRef<any, T> {
let emitted: T | undefined = undefined;

const subscription = source.subscribe(
(value) => {
emitted = value;
this.send(toSCXMLEvent(value, { origin: id }));
},
(err) => {
Expand All @@ -1172,6 +1182,7 @@ export class Interpreter<
return source.subscribe(next, handleError, complete);
},
stop: () => subscription.unsubscribe(),
getSnapshot: () => emitted,
toJSON() {
return { id };
}
Expand Down Expand Up @@ -1215,6 +1226,7 @@ export class Interpreter<
return { unsubscribe: () => void 0 };
},
stop: dispose || undefined,
getSnapshot: () => undefined,
toJSON() {
return { id };
}
Expand Down Expand Up @@ -1267,6 +1279,10 @@ export class Interpreter<
public [symbolObservable]() {
return this;
}

public getSnapshot() {
return this._state!;
}
}

const resolveSpawnOptions = (nameOrOptions?: string | SpawnOptions) => {
Expand Down Expand Up @@ -1305,6 +1321,7 @@ export function spawn(
}") outside of a service. This will have no effect.`
);
}

if (service) {
return service.spawn(entity, resolvedOptions.name, resolvedOptions);
} else {
Expand Down
5 changes: 4 additions & 1 deletion packages/core/src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1278,16 +1278,19 @@ export interface ActorRef<TEvent extends EventObject, TEmitted = any>
export interface SpawnedActorRef<TEvent extends EventObject, TEmitted = any>
extends ActorRef<TEvent, TEmitted> {
id: string;
getSnapshot: () => TEmitted | undefined;
stop?: () => void;
toJSON?: () => any;
}

export type ActorRefFrom<
T extends StateMachine<any, any, any>
T extends StateMachine<any, any, any> | Promise<any>
> = T extends StateMachine<infer TContext, any, infer TEvent, infer TTypestate>
? SpawnedActorRef<TEvent, State<TContext, TEvent, any, TTypestate>> & {
state: State<TContext, TEvent, any, TTypestate>;
}
: T extends Promise<infer U>
? SpawnedActorRef<never, U>
: never;

export type AnyInterpreter = Interpreter<any, any, any, any>;
Expand Down
64 changes: 35 additions & 29 deletions packages/core/test/actor.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -438,12 +438,16 @@ describe('actors', () => {
});

it('should spawn null actors if not used within a service', () => {
const nullActorMachine = Machine<{ ref?: ActorRef<any> }>({
interface TestContext {
ref?: ActorRef<any>;
}

const nullActorMachine = Machine<TestContext>({
initial: 'foo',
context: { ref: undefined },
states: {
foo: {
entry: assign({
entry: assign<TestContext>({
ref: () => spawn(Promise.resolve(42))
})
}
Expand Down Expand Up @@ -572,11 +576,13 @@ describe('actors', () => {
}
});

const parentMachine = Machine<{
ref: any;
refNoSync: any;
refNoSyncDefault: any;
}>({
interface TestContext {
ref?: ActorRefFrom<typeof childMachine>;
refNoSync?: ActorRefFrom<typeof childMachine>;
refNoSyncDefault?: ActorRefFrom<typeof childMachine>;
}

const parentMachine = Machine<TestContext>({
id: 'parent',
context: {
ref: undefined,
Expand All @@ -586,7 +592,7 @@ describe('actors', () => {
initial: 'foo',
states: {
foo: {
entry: assign({
entry: assign<TestContext>({
ref: () => spawn(childMachine, { sync: true }),
refNoSync: () => spawn(childMachine, { sync: false }),
refNoSyncDefault: () => spawn(childMachine)
Expand All @@ -603,7 +609,7 @@ describe('actors', () => {
const service = interpret(parentMachine, {
id: 'a-service'
}).onTransition((s) => {
if (s.context.ref.state.context.value === 42) {
if (s.context.ref?.getSnapshot()?.context.value === 42) {
res();
}
});
Expand All @@ -616,14 +622,16 @@ describe('actors', () => {
const service = interpret(parentMachine, {
id: 'b-service'
}).onTransition((s) => {
if (s.context.refNoSync.state.context.value === 42) {
if (s.context.refNoSync?.getSnapshot()?.context.value === 42) {
rej(new Error('value change caused transition'));
}
});
service.start();

setTimeout(() => {
expect(service.state.context.refNoSync.state.context.value).toBe(42);
expect(
service.state.context.refNoSync?.getSnapshot()?.context.value
).toBe(42);
res();
}, 30);
});
Expand All @@ -634,15 +642,15 @@ describe('actors', () => {
const service = interpret(parentMachine, {
id: 'c-service'
}).onTransition((s) => {
if (s.context.refNoSyncDefault.state.context.value === 42) {
if (s.context.refNoSyncDefault?.getSnapshot()?.context.value === 42) {
rej(new Error('value change caused transition'));
}
});
service.start();

setTimeout(() => {
expect(
service.state.context.refNoSyncDefault.state.context.value
service.state.context.refNoSyncDefault?.getSnapshot()?.context.value
).toBe(42);
res();
}, 30);
Expand Down Expand Up @@ -678,10 +686,7 @@ describe('actors', () => {

interpret(syncMachine)
.onTransition((state) => {
if (
state.context.ref &&
state.context.ref.state.matches('inactive')
) {
if (state.context.ref?.getSnapshot()?.matches('inactive')) {
expect(state.changed).toBe(true);
done();
}
Expand Down Expand Up @@ -714,7 +719,7 @@ describe('actors', () => {
context: {},
states: {
same: {
entry: assign({
entry: assign<SyncMachineContext>({
ref: () => spawn(syncChildMachine, falseSyncOption)
})
}
Expand All @@ -725,17 +730,17 @@ describe('actors', () => {
.onTransition((state) => {
if (
state.context.ref &&
state.context.ref.state.matches('inactive')
state.context.ref.getSnapshot()?.matches('inactive')
) {
expect(state.changed).toBe(false);
}
})
.start();

setTimeout(() => {
expect(service.state.context.ref!.state.matches('inactive')).toBe(
true
);
expect(
service.state.context.ref?.getSnapshot()?.matches('inactive')
).toBe(true);
done();
}, 20);
});
Expand Down Expand Up @@ -764,7 +769,7 @@ describe('actors', () => {
context: {},
states: {
same: {
entry: assign({
entry: assign<SyncMachineContext>({
ref: () => spawn(syncChildMachine, falseSyncOption)
})
}
Expand All @@ -773,10 +778,7 @@ describe('actors', () => {

interpret(syncMachine)
.onTransition((state) => {
if (
state.context.ref &&
state.context.ref.state.matches('inactive')
) {
if (state.context.ref?.getSnapshot()?.matches('inactive')) {
expect(state.changed).toBe(true);
done();
}
Expand All @@ -787,12 +789,16 @@ describe('actors', () => {
it('should only spawn an actor in an initial state of a child that gets invoked in the initial state of a parent when the parent gets started', () => {
let spawnCounter = 0;

const child = Machine({
interface TestContext {
promise?: ActorRefFrom<Promise<string>>;
}

const child = Machine<TestContext>({
initial: 'bar',
context: {},
states: {
bar: {
entry: assign({
entry: assign<TestContext>({
promise: () => {
return spawn(() => {
spawnCounter++;
Expand Down
Loading

0 comments on commit a4256dc

Please sign in to comment.