-
-
Notifications
You must be signed in to change notification settings - Fork 1.3k
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
[core] Synchronizers #5071
base: main
Are you sure you want to change the base?
[core] Synchronizers #5071
Changes from all commits
1a38668
928cd2e
fb46f2a
e77adf5
16f119b
8f334de
3abf8d2
ac68a1d
244e3eb
e0ce461
c659467
03854c3
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,18 @@ | ||
--- | ||
'xstate': minor | ||
--- | ||
|
||
Added support for synchronizers in XState, allowing state persistence and synchronization across different storage mechanisms. | ||
|
||
- Introduced `Synchronizer` interface for implementing custom synchronization logic | ||
- Added `sync` option to `createActor` for attaching synchronizers to actors | ||
|
||
```ts | ||
import { createActor } from 'xstate'; | ||
import { someMachine } from './someMachine'; | ||
import { createLocalStorageSync } from './localStorageSynchronizer'; | ||
|
||
const actor = createActor(someMachine, { | ||
sync: createLocalStorageSync('someKey') | ||
}); | ||
``` |
Original file line number | Diff line number | Diff line change | ||||
---|---|---|---|---|---|---|
|
@@ -22,7 +22,8 @@ import type { | |||||
InputFrom, | ||||||
IsNotNever, | ||||||
Snapshot, | ||||||
SnapshotFrom | ||||||
SnapshotFrom, | ||||||
Synchronizer | ||||||
} from './types.ts'; | ||||||
import { | ||||||
ActorOptions, | ||||||
|
@@ -119,6 +120,9 @@ export class Actor<TLogic extends AnyActorLogic> | |||||
|
||||||
public src: string | AnyActorLogic; | ||||||
|
||||||
private _synchronizer?: Synchronizer<any>; | ||||||
private _synchronizerSubscription?: Subscription; | ||||||
|
||||||
/** | ||||||
* Creates a new actor instance for the given logic with the provided options, | ||||||
* if any. | ||||||
|
@@ -207,7 +211,23 @@ export class Actor<TLogic extends AnyActorLogic> | |||||
this.system._set(systemId, this); | ||||||
} | ||||||
|
||||||
this._initState(options?.snapshot ?? options?.state); | ||||||
this._synchronizer = options?.sync; | ||||||
|
||||||
const initialSnapshot = | ||||||
this._synchronizer?.getSnapshot() ?? options?.snapshot ?? options?.state; | ||||||
|
||||||
this._initState(initialSnapshot); | ||||||
|
||||||
if (this._synchronizer) { | ||||||
this._synchronizerSubscription = this._synchronizer.subscribe( | ||||||
(rawSnapshot) => { | ||||||
const restoredSnapshot = | ||||||
this.logic.restoreSnapshot?.(rawSnapshot, this._actorScope) ?? | ||||||
rawSnapshot; | ||||||
this.update(restoredSnapshot, { type: '@xstate.sync' }); | ||||||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. We don't use
Suggested change
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I think it should get enqueued and not handled immediately because with this model we allow for |
||||||
} | ||||||
); | ||||||
} | ||||||
|
||||||
if (systemId && (this._snapshot as any).status !== 'active') { | ||||||
this.system._unregister(this); | ||||||
|
@@ -263,6 +283,7 @@ export class Actor<TLogic extends AnyActorLogic> | |||||
|
||||||
switch ((this._snapshot as any).status) { | ||||||
case 'active': | ||||||
this._synchronizer?.setSnapshot(snapshot); | ||||||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. why only for the |
||||||
for (const observer of this.observers) { | ||||||
try { | ||||||
observer.next?.(snapshot); | ||||||
|
@@ -567,6 +588,7 @@ export class Actor<TLogic extends AnyActorLogic> | |||||
return this; | ||||||
} | ||||||
this.mailbox.clear(); | ||||||
this._synchronizerSubscription?.unsubscribe(); | ||||||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. what snapshot should the synchronizer be aware of when somebody does smth like this: const actorRef = createActor(m, { sync: someSynchronizer })
// ... do stuff
actorRef.stop()
actorRef.getSnapshot() // .status === 'stopped' If I'm not mistaken the known snapshot after this operation has a stopped status but the synchronizer won't be aware of it because we have unsubscribed earlier. |
||||||
if (this._processingStatus === ProcessingStatus.NotStarted) { | ||||||
this._processingStatus = ProcessingStatus.Stopped; | ||||||
return this; | ||||||
|
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,176 @@ | ||
import { | ||
createActor, | ||
createMachine, | ||
Observer, | ||
Synchronizer, | ||
toObserver, | ||
waitFor | ||
} from '../src'; | ||
|
||
describe('synchronizers', () => { | ||
it('work with a synchronous synchronizer', () => { | ||
const snapshotRef = { | ||
current: JSON.stringify({ value: 'b', children: {}, status: 'active' }) | ||
}; | ||
const pseudoStorage = { | ||
getItem: (key: string) => { | ||
return JSON.parse(snapshotRef.current); | ||
}, | ||
setItem: (key: string, value: string) => { | ||
snapshotRef.current = value; | ||
} | ||
}; | ||
const createStorageSync = (key: string): Synchronizer<any> => { | ||
const observers = new Set(); | ||
return { | ||
getSnapshot: () => pseudoStorage.getItem(key), | ||
setSnapshot: (snapshot) => { | ||
pseudoStorage.setItem(key, JSON.stringify(snapshot)); | ||
}, | ||
subscribe: (o) => { | ||
const observer = toObserver(o); | ||
|
||
const state = pseudoStorage.getItem(key); | ||
|
||
observer.next?.(state); | ||
|
||
observers.add(observer); | ||
|
||
return { | ||
unsubscribe: () => { | ||
observers.delete(observer); | ||
} | ||
}; | ||
} | ||
}; | ||
}; | ||
|
||
const machine = createMachine({ | ||
initial: 'a', | ||
states: { | ||
a: {}, | ||
b: { | ||
on: { | ||
next: 'c' | ||
} | ||
}, | ||
c: {} | ||
} | ||
}); | ||
|
||
const actor = createActor(machine, { | ||
sync: createStorageSync('test') | ||
}).start(); | ||
|
||
expect(actor.getSnapshot().value).toBe('b'); | ||
|
||
actor.send({ type: 'next' }); | ||
|
||
expect(actor.getSnapshot().value).toBe('c'); | ||
|
||
expect(pseudoStorage.getItem('test').value).toBe('c'); | ||
}); | ||
|
||
it('work with an asynchronous synchronizer', async () => { | ||
let snapshotRef = { | ||
current: undefined as any | ||
}; | ||
let onChangeRef = { | ||
current: (() => {}) as (value: any) => void | ||
}; | ||
const pseudoStorage = { | ||
getItem: async (key: string) => { | ||
if (!snapshotRef.current) { | ||
return undefined; | ||
} | ||
return JSON.parse(snapshotRef.current); | ||
}, | ||
setItem: (key: string, value: string, source?: 'sync') => { | ||
snapshotRef.current = value; | ||
|
||
if (source !== 'sync') { | ||
onChangeRef.current(JSON.parse(value)); | ||
} | ||
}, | ||
subscribe: (fn: (value: any) => void) => { | ||
onChangeRef.current = fn; | ||
} | ||
}; | ||
|
||
const createStorageSync = (key: string): Synchronizer<any> => { | ||
const observers = new Set<Observer<any>>(); | ||
|
||
pseudoStorage.subscribe((value) => { | ||
observers.forEach((observer) => { | ||
observer.next?.(value); | ||
}); | ||
}); | ||
|
||
const getSnapshot = () => { | ||
if (!snapshotRef.current) { | ||
return undefined; | ||
} | ||
return JSON.parse(snapshotRef.current); | ||
}; | ||
|
||
const storageSync = { | ||
getSnapshot, | ||
setSnapshot: (snapshot) => { | ||
const s = JSON.stringify(snapshot); | ||
pseudoStorage.setItem(key, s, 'sync'); | ||
}, | ||
subscribe: (o) => { | ||
const observer = toObserver(o); | ||
|
||
const state = getSnapshot(); | ||
|
||
if (state) { | ||
observer.next?.(state); | ||
} | ||
|
||
observers.add(observer); | ||
|
||
return { | ||
unsubscribe: () => { | ||
observers.delete(observer); | ||
} | ||
}; | ||
} | ||
} satisfies Synchronizer<any>; | ||
|
||
setTimeout(() => { | ||
pseudoStorage.setItem( | ||
'key', | ||
JSON.stringify({ value: 'b', children: {}, status: 'active' }) | ||
); | ||
}, 100); | ||
|
||
return storageSync; | ||
}; | ||
|
||
const machine = createMachine({ | ||
initial: 'a', | ||
states: { | ||
a: {}, | ||
b: { | ||
on: { | ||
next: 'c' | ||
} | ||
}, | ||
c: {} | ||
} | ||
}); | ||
|
||
const actor = createActor(machine, { | ||
sync: createStorageSync('test') | ||
}).start(); | ||
|
||
expect(actor.getSnapshot().value).toBe('a'); | ||
|
||
await waitFor(actor, () => actor.getSnapshot().value === 'b'); | ||
|
||
actor.send({ type: 'next' }); | ||
|
||
expect(actor.getSnapshot().value).toBe('c'); | ||
}); | ||
}); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
this could likely be reverted - no tests fails without this
if
😉