From 1a38668dcb08be96b9b39b3b8337c76b627a39d0 Mon Sep 17 00:00:00 2001 From: David Khourshid Date: Sat, 17 Aug 2024 12:24:41 -0400 Subject: [PATCH 1/6] WIP Synchronizer --- packages/core/src/createActor.ts | 15 +++++++- packages/core/src/types.ts | 13 +++++++ packages/core/test/sync.test.ts | 66 ++++++++++++++++++++++++++++++++ 3 files changed, 92 insertions(+), 2 deletions(-) create mode 100644 packages/core/test/sync.test.ts diff --git a/packages/core/src/createActor.ts b/packages/core/src/createActor.ts index 3ad250edcc..023f060903 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,8 @@ export class Actor public src: string | AnyActorLogic; + private _synchronizer?: Synchronizer; + /** * Creates a new actor instance for the given logic with the provided options, * if any. @@ -206,7 +209,12 @@ 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 (systemId && (this._snapshot as any).status !== 'active') { this.system._unregister(this); @@ -229,6 +237,8 @@ export class Actor output: undefined, error: err } as any; + + throw err; } } @@ -262,6 +272,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); diff --git a/packages/core/src/types.ts b/packages/core/src/types.ts index 13fbbcf924..73778b5498 100644 --- a/packages/core/src/types.ts +++ b/packages/core/src/types.ts @@ -1880,6 +1880,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..f0a2876857 --- /dev/null +++ b/packages/core/test/sync.test.ts @@ -0,0 +1,66 @@ +import { createActor, createMachine, Synchronizer, toObserver } from '../src'; + +describe('sync', () => { + it('work with a synchronous synchronizer', () => { + let 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'); + }); +}); From fb46f2acb66b769b12b37d44fce861d935904880 Mon Sep 17 00:00:00 2001 From: David Khourshid Date: Sat, 24 Aug 2024 08:55:52 -0400 Subject: [PATCH 2/6] WIP --- packages/core/test/sync.test.ts | 94 ++++++++++++++++++++++++++++++++- 1 file changed, 93 insertions(+), 1 deletion(-) diff --git a/packages/core/test/sync.test.ts b/packages/core/test/sync.test.ts index f0a2876857..5bd5d1023d 100644 --- a/packages/core/test/sync.test.ts +++ b/packages/core/test/sync.test.ts @@ -1,4 +1,10 @@ -import { createActor, createMachine, Synchronizer, toObserver } from '../src'; +import { + createActor, + createMachine, + Observer, + Synchronizer, + toObserver +} from '../src'; describe('sync', () => { it('work with a synchronous synchronizer', () => { @@ -63,4 +69,90 @@ describe('sync', () => { expect(pseudoStorage.getItem('test').value).toBe('c'); }); + + it.only('work with an asynchronous synchronizer', () => { + let snapshotRef = { + current: JSON.stringify({ value: 'b', children: {}, status: 'active' }) + }; + let onChangeRef = { + current: (() => {}) as (value: any) => void + }; + const pseudoStorage = { + getItem: async (key: string) => { + return JSON.parse(snapshotRef.current); + }, + setItem: (key: string, value: string) => { + snapshotRef.current = value; + + onChangeRef.current(JSON.parse(value)); + }, + subscribe: (fn: (value: any) => void) => { + onChangeRef.current = fn; + } + }; + + const createStorageSync = (key: string): Synchronizer => { + const observers = new Set>(); + let cachedRef = { + current: JSON.stringify({ + value: {}, + status: 'pending', + children: {} + }) + }; + + pseudoStorage.subscribe((value) => { + observers.forEach((observer) => { + observer.next?.(value); + }); + }); + + return { + getSnapshot: () => { + return JSON.parse(cachedRef.current); + }, + 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'); + }); }); From 16f119b5561d88c77a29bc749164d05cb0a17b7e Mon Sep 17 00:00:00 2001 From: David Khourshid Date: Sat, 7 Sep 2024 16:12:38 -0400 Subject: [PATCH 3/6] Improvements --- packages/core/src/StateMachine.ts | 14 ++++---- packages/core/src/createActor.ts | 13 +++++++ packages/core/test/sync.test.ts | 60 ++++++++++++++++++++----------- 3 files changed, 60 insertions(+), 27 deletions(-) diff --git a/packages/core/src/StateMachine.ts b/packages/core/src/StateMachine.ts index 490413207e..5f07ad067d 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 023f060903..7e5fba20dd 100644 --- a/packages/core/src/createActor.ts +++ b/packages/core/src/createActor.ts @@ -121,6 +121,7 @@ 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, @@ -216,6 +217,17 @@ export class Actor 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); } @@ -576,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/test/sync.test.ts b/packages/core/test/sync.test.ts index 5bd5d1023d..8325ee50ab 100644 --- a/packages/core/test/sync.test.ts +++ b/packages/core/test/sync.test.ts @@ -3,7 +3,8 @@ import { createMachine, Observer, Synchronizer, - toObserver + toObserver, + waitFor } from '../src'; describe('sync', () => { @@ -70,21 +71,26 @@ describe('sync', () => { expect(pseudoStorage.getItem('test').value).toBe('c'); }); - it.only('work with an asynchronous synchronizer', () => { + it('work with an asynchronous synchronizer', async () => { let snapshotRef = { - current: JSON.stringify({ value: 'b', children: {}, status: 'active' }) + 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) => { + setItem: (key: string, value: string, source?: 'sync') => { snapshotRef.current = value; - onChangeRef.current(JSON.parse(value)); + if (source !== 'sync') { + onChangeRef.current(JSON.parse(value)); + } }, subscribe: (fn: (value: any) => void) => { onChangeRef.current = fn; @@ -93,13 +99,6 @@ describe('sync', () => { const createStorageSync = (key: string): Synchronizer => { const observers = new Set>(); - let cachedRef = { - current: JSON.stringify({ - value: {}, - status: 'pending', - children: {} - }) - }; pseudoStorage.subscribe((value) => { observers.forEach((observer) => { @@ -107,19 +106,27 @@ describe('sync', () => { }); }); - return { - getSnapshot: () => { - return JSON.parse(cachedRef.current); - }, + const getSnapshot = () => { + if (!snapshotRef.current) { + return undefined; + } + return JSON.parse(snapshotRef.current); + }; + + const storageSync = { + getSnapshot, setSnapshot: (snapshot) => { - pseudoStorage.setItem(key, JSON.stringify(snapshot)); + const s = JSON.stringify(snapshot); + pseudoStorage.setItem(key, s, 'sync'); }, subscribe: (o) => { const observer = toObserver(o); - const state = pseudoStorage.getItem(key); + const state = getSnapshot(); - observer.next?.(state); + if (state) { + observer.next?.(state); + } observers.add(observer); @@ -129,7 +136,16 @@ describe('sync', () => { } }; } - }; + } satisfies Synchronizer; + + setTimeout(() => { + pseudoStorage.setItem( + 'key', + JSON.stringify({ value: 'b', children: {}, status: 'active' }) + ); + }, 100); + + return storageSync; }; const machine = createMachine({ @@ -149,7 +165,9 @@ describe('sync', () => { sync: createStorageSync('test') }).start(); - expect(actor.getSnapshot().value).toBe('b'); + expect(actor.getSnapshot().value).toBe('a'); + + await waitFor(actor, () => actor.getSnapshot().value === 'b'); actor.send({ type: 'next' }); From 3abf8d239a3aabb462d3eec113e1af9a0de4c061 Mon Sep 17 00:00:00 2001 From: David Khourshid Date: Thu, 12 Sep 2024 10:43:27 -0400 Subject: [PATCH 4/6] Remove throw err --- packages/core/src/createActor.ts | 2 -- 1 file changed, 2 deletions(-) diff --git a/packages/core/src/createActor.ts b/packages/core/src/createActor.ts index 0664edc7dc..a96ea997ff 100644 --- a/packages/core/src/createActor.ts +++ b/packages/core/src/createActor.ts @@ -249,8 +249,6 @@ export class Actor output: undefined, error: err } as any; - - throw err; } } From 244e3eb8659515e1dc7b78458b9a2db2500b0c9c Mon Sep 17 00:00:00 2001 From: David Khourshid Date: Sun, 29 Sep 2024 14:42:00 -0400 Subject: [PATCH 5/6] Cleanup --- packages/core/test/sync.test.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/core/test/sync.test.ts b/packages/core/test/sync.test.ts index 8325ee50ab..40e6734a2b 100644 --- a/packages/core/test/sync.test.ts +++ b/packages/core/test/sync.test.ts @@ -7,9 +7,9 @@ import { waitFor } from '../src'; -describe('sync', () => { +describe('synchronizers', () => { it('work with a synchronous synchronizer', () => { - let snapshotRef = { + const snapshotRef = { current: JSON.stringify({ value: 'b', children: {}, status: 'active' }) }; const pseudoStorage = { From e0ce46136f735e19d5d2103f9fdc5f847bd1fd24 Mon Sep 17 00:00:00 2001 From: David Khourshid Date: Mon, 30 Sep 2024 01:02:22 -0400 Subject: [PATCH 6/6] Add changeset --- .changeset/silly-needles-applaud.md | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) create mode 100644 .changeset/silly-needles-applaud.md 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') +}); +```