diff --git a/.changeset/silly-needles-applaud.md b/.changeset/silly-needles-applaud.md new file mode 100644 index 0000000000..9589c7e648 --- /dev/null +++ b/.changeset/silly-needles-applaud.md @@ -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') +}); +``` diff --git a/packages/core/src/StateMachine.ts b/packages/core/src/StateMachine.ts index c4ce523bfb..08a7de2a97 100644 --- a/packages/core/src/StateMachine.ts +++ b/packages/core/src/StateMachine.ts @@ -471,13 +471,15 @@ export class StateMachine< TConfig > ): void { - Object.values(snapshot.children as Record).forEach( - (child: any) => { - if (child.getSnapshot().status === 'active') { - child.start(); + if (snapshot.children) { + Object.values(snapshot.children as Record).forEach( + (child: any) => { + if (child.getSnapshot().status === 'active') { + child.start(); + } } - } - ); + ); + } } public getStateNodeById(stateId: string): StateNode { diff --git a/packages/core/src/createActor.ts b/packages/core/src/createActor.ts index ebbc9a4ea1..c245b43a3f 100644 --- a/packages/core/src/createActor.ts +++ b/packages/core/src/createActor.ts @@ -22,7 +22,8 @@ import type { InputFrom, IsNotNever, Snapshot, - SnapshotFrom + SnapshotFrom, + Synchronizer } from './types.ts'; import { ActorOptions, @@ -119,6 +120,9 @@ export class Actor public src: string | AnyActorLogic; + private _synchronizer?: Synchronizer; + 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 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' }); + } + ); + } if (systemId && (this._snapshot as any).status !== 'active') { this.system._unregister(this); @@ -263,6 +283,7 @@ export class Actor switch ((this._snapshot as any).status) { case 'active': + this._synchronizer?.setSnapshot(snapshot); for (const observer of this.observers) { try { observer.next?.(snapshot); @@ -567,6 +588,7 @@ export class Actor return this; } this.mailbox.clear(); + this._synchronizerSubscription?.unsubscribe(); if (this._processingStatus === ProcessingStatus.NotStarted) { this._processingStatus = ProcessingStatus.Stopped; return this; diff --git a/packages/core/src/types.ts b/packages/core/src/types.ts index 624d75c194..f2759590df 100644 --- a/packages/core/src/types.ts +++ b/packages/core/src/types.ts @@ -1884,6 +1884,19 @@ export interface ActorOptions { inspect?: | Observer | ((inspectionEvent: InspectionEvent) => void); + + sync?: Synchronizer>; +} + +export interface Synchronizer extends Subscribable { + /** + * Gets the snapshot or undefined + * + * An undefined snapshot means the synchronizer does not intend to override + * the initial or provided snapshot of the actor + */ + getSnapshot(): Snapshot | undefined; + setSnapshot(snapshot: T): void; } export type AnyActor = Actor; diff --git a/packages/core/test/sync.test.ts b/packages/core/test/sync.test.ts new file mode 100644 index 0000000000..40e6734a2b --- /dev/null +++ b/packages/core/test/sync.test.ts @@ -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 => { + 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 => { + const observers = new Set>(); + + 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; + + 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'); + }); +});