@doeixd/machine 0.0.20 → 0.0.21

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/actor.ts ADDED
@@ -0,0 +1,284 @@
1
+ import {
2
+ Event,
3
+ BaseMachine,
4
+ TransitionNames,
5
+ TransitionArgs,
6
+ MaybePromise,
7
+ createMachine
8
+ } from './index';
9
+
10
+ // =============================================================================
11
+ // TYPES
12
+ // =============================================================================
13
+
14
+ /**
15
+ * A standard interface for interacting with any actor-like entity.
16
+ */
17
+ export interface ActorRef<T> {
18
+ dispatch: (event: any) => void;
19
+ getSnapshot: () => T;
20
+ subscribe: (observer: (state: T) => void) => () => void;
21
+ }
22
+
23
+ /**
24
+ * Inspection event type.
25
+ */
26
+ export type InspectionEvent = {
27
+ type: '@actor/send'; // Extendable for lifecycle
28
+ actor: ActorRef<any>;
29
+ event: any;
30
+ snapshot: any;
31
+ };
32
+
33
+ // =============================================================================
34
+ // ACTOR CLASS
35
+ // =============================================================================
36
+
37
+ /**
38
+ * A reactive container for a state machine that handles dispatching,
39
+ * queueing of async transitions, and state observability.
40
+ */
41
+ export class Actor<M extends BaseMachine<any>> implements ActorRef<M> {
42
+ private _state: M;
43
+ private _observers: Set<(state: M) => void> = new Set();
44
+ private _queue: Array<Event<M>> = [];
45
+ private _processing = false;
46
+
47
+ // Global inspector
48
+ private static _inspector: ((event: InspectionEvent) => void) | null = null;
49
+
50
+ /**
51
+ * Registers a global inspector.
52
+ */
53
+ static inspect(inspector: (event: InspectionEvent) => void) {
54
+ Actor._inspector = inspector;
55
+ }
56
+
57
+ /**
58
+ * The "Magic" Dispatcher.
59
+ * Maps machine transition names to callable functions.
60
+ */
61
+ readonly send: {
62
+ [K in TransitionNames<M>]: (...args: TransitionArgs<M, K>) => void;
63
+ };
64
+
65
+ /**
66
+ * A stable reference to the dispatch method, useful for passing around.
67
+ */
68
+ readonly ref: {
69
+ send: (event: Event<M>) => void;
70
+ };
71
+
72
+ constructor(initialMachine: M) {
73
+ this._state = initialMachine;
74
+
75
+ // Pattern B: Reference to self for event-based dispatch
76
+ this.ref = {
77
+ send: (event) => this.dispatch(event)
78
+ };
79
+
80
+ // Pattern A: Proxy for RPC-style dispatch
81
+ this.send = new Proxy({} as any, {
82
+ get: (_target, prop) => {
83
+ return (...args: any[]) => {
84
+ this.dispatch({ type: prop as any, args: args as any } as unknown as Event<M>);
85
+ };
86
+ }
87
+ });
88
+ }
89
+
90
+ /**
91
+ * Returns the current immutable snapshot of the machine.
92
+ */
93
+ getSnapshot(): M {
94
+ return this._state;
95
+ }
96
+
97
+ /**
98
+ * Subscribes to state changes.
99
+ * @param observer Callback function to be invoked on every state change.
100
+ * @returns Unsubscribe function.
101
+ */
102
+ subscribe(observer: (state: M) => void): () => void {
103
+ this._observers.add(observer);
104
+ return () => {
105
+ this._observers.delete(observer);
106
+ };
107
+ }
108
+
109
+ /**
110
+ * Selects a slice of the state.
111
+ */
112
+ select<T>(selector: (state: M) => T): T {
113
+ return selector(this._state);
114
+ }
115
+
116
+ /**
117
+ * Starts the actor.
118
+ */
119
+ start(): this {
120
+ return this;
121
+ }
122
+
123
+ /**
124
+ * Stops the actor.
125
+ */
126
+ stop(): void {
127
+ this._observers.clear();
128
+ }
129
+
130
+ /**
131
+ * Dispatches an event to the actor.
132
+ * Handles both sync and async transitions.
133
+ */
134
+ dispatch(event: Event<M>): void {
135
+ // Inspection
136
+ if (Actor._inspector) {
137
+ Actor._inspector({
138
+ type: '@actor/send',
139
+ actor: this,
140
+ event,
141
+ snapshot: this._state
142
+ });
143
+ }
144
+
145
+ if (this._processing) {
146
+ this._queue.push(event);
147
+ return;
148
+ }
149
+
150
+ this._processing = true;
151
+ this._queue.push(event);
152
+ this._flush();
153
+ }
154
+
155
+ private _flush() {
156
+ while (this._queue.length > 0) {
157
+ const event = this._queue[0];
158
+ this._queue.shift();
159
+
160
+ const transitions = this._state as any;
161
+ const fn = transitions[event.type];
162
+
163
+ if (typeof fn !== 'function') {
164
+ console.warn(`[Actor] Transition '${String(event.type)}' not found.`);
165
+ continue;
166
+ }
167
+
168
+ let result: MaybePromise<M>;
169
+ try {
170
+ result = fn.apply(this._state.context, event.args);
171
+ } catch (error) {
172
+ console.error(`[Actor] Error in transition '${String(event.type)}':`, error);
173
+ continue;
174
+ }
175
+
176
+ if (result instanceof Promise) {
177
+ result.then((nextState) => {
178
+ this._state = nextState;
179
+ this._notify();
180
+ this._flush();
181
+ }).catch((error) => {
182
+ console.error(`[Actor] Async error in transition '${String(event.type)}':`, error);
183
+ this._flush();
184
+ });
185
+ return;
186
+ } else {
187
+ this._state = result;
188
+ this._notify();
189
+ }
190
+ }
191
+ this._processing = false;
192
+ }
193
+
194
+ private _notify() {
195
+ const snapshot = this.getSnapshot();
196
+ this._observers.forEach(obs => obs(snapshot));
197
+ }
198
+ }
199
+
200
+ // =============================================================================
201
+ // INTEROP & HELPERS
202
+ // =============================================================================
203
+
204
+ /**
205
+ * Creates a new Actor instance from a machine.
206
+ */
207
+ export function createActor<M extends BaseMachine<any>>(machine: M): Actor<M> {
208
+ return new Actor(machine);
209
+ }
210
+
211
+ /**
212
+ * Spawns an actor from a machine. Alias for createActor.
213
+ */
214
+ export function spawn<M extends BaseMachine<any>>(machine: M): ActorRef<M> {
215
+ return createActor(machine);
216
+ }
217
+
218
+ /**
219
+ * Creates an actor-like machine from a Promise.
220
+ */
221
+ export function fromPromise<T>(promiseFn: () => Promise<T>) {
222
+ type PromiseContext =
223
+ | { status: 'pending'; data: undefined; error: undefined }
224
+ | { status: 'resolved'; data: T; error: undefined }
225
+ | { status: 'rejected'; data: undefined; error: any };
226
+
227
+ const initial: PromiseContext = { status: 'pending', data: undefined, error: undefined };
228
+
229
+ const machine = createMachine<PromiseContext>(initial,
230
+ (next) => ({
231
+ resolve(data: T) {
232
+ return next({ status: 'resolved' as const, data, error: undefined });
233
+ },
234
+ reject(error: any) {
235
+ return next({ status: 'rejected' as const, error, data: undefined });
236
+ }
237
+ })
238
+ );
239
+
240
+ const actor = createActor(machine);
241
+
242
+ promiseFn()
243
+ .then(data => (actor.send as any).resolve(data))
244
+ .catch(err => (actor.send as any).reject(err));
245
+
246
+ return actor;
247
+ }
248
+
249
+ /**
250
+ * Creates an actor-like machine from an Observable.
251
+ */
252
+ export function fromObservable<T>(observable: { subscribe: (next: (val: T) => void, error?: (err: any) => void, complete?: () => void) => { unsubscribe: () => void } }) {
253
+ type ObsContext =
254
+ | { status: 'active'; value: undefined; error: undefined }
255
+ | { status: 'active'; value: T; error: undefined }
256
+ | { status: 'done'; value: undefined; error: undefined }
257
+ | { status: 'error'; value: undefined; error: any };
258
+
259
+ const initial: ObsContext = { status: 'active', value: undefined, error: undefined };
260
+
261
+ const machine = createMachine<ObsContext>(initial,
262
+ (next) => ({
263
+ next(value: T) {
264
+ return next({ status: 'active' as const, value, error: undefined });
265
+ },
266
+ error(error: any) {
267
+ return next({ status: 'error' as const, error, value: undefined });
268
+ },
269
+ complete() {
270
+ return next({ status: 'done' as const, value: undefined, error: undefined });
271
+ }
272
+ })
273
+ );
274
+
275
+ const actor = createActor(machine);
276
+
277
+ observable.subscribe(
278
+ (val) => (actor.send as any).next(val),
279
+ (err) => (actor.send as any).error(err),
280
+ () => (actor.send as any).complete()
281
+ );
282
+
283
+ return actor;
284
+ }
package/src/index.ts CHANGED
@@ -127,7 +127,7 @@ export type TransitionNames<M extends BaseMachine<any>> = keyof Omit<M, "context
127
127
  export type BaseMachine<C extends object> = {
128
128
  /** The readonly state of the machine. */
129
129
  readonly context: C;
130
- } & Record<string, (...args: any[]) => any>;
130
+ };
131
131
 
132
132
  /**
133
133
  * Helper to make a type deeply readonly (freezes nested objects).
@@ -1148,4 +1148,18 @@ export {
1148
1148
  type IsExhaustive,
1149
1149
  type WhenBuilder,
1150
1150
  type Matcher
1151
- } from './matcher';
1151
+ } from './matcher';
1152
+
1153
+ // =============================================================================
1154
+ // SECTION: ACTOR MODEL
1155
+ // =============================================================================
1156
+
1157
+ export {
1158
+ Actor,
1159
+ createActor,
1160
+ spawn,
1161
+ fromPromise,
1162
+ fromObservable,
1163
+ type ActorRef,
1164
+ type InspectionEvent
1165
+ } from './actor';
package/src/react.ts CHANGED
@@ -33,6 +33,10 @@
33
33
  * - **Returns:** A `Provider` and consumer hooks (`useContext`, `useSelector`, etc.).
34
34
  * - A utility to provide a machine created with `useMachine` or `useEnsemble` to
35
35
  * the entire component tree below it.
36
+ *
37
+ * 5. **`useActor(actor)`**:
38
+ * - **Best for:** Using the Actor model.
39
+ * - **Returns:** The current machine snapshot.
36
40
  */
37
41
 
38
42
  import {
@@ -43,18 +47,22 @@ import {
43
47
  createContext,
44
48
  useContext,
45
49
  createElement,
50
+ useSyncExternalStore,
46
51
  type ReactNode,
47
52
  } from 'react';
48
53
 
49
54
  import {
50
55
  Machine,
51
- createRunner,
56
+ runMachine, // Was createRunner
52
57
  createEnsemble,
53
- type Runner,
54
58
  type Ensemble,
55
59
  type StateStore,
60
+ type Actor,
61
+ BaseMachine
56
62
  } from './index';
57
63
 
64
+ export type Runner<M extends Machine<any>> = ReturnType<typeof runMachine<M>>;
65
+
58
66
  // =============================================================================
59
67
  // HOOK 1: useMachine (Ergonomic local state)
60
68
  // =============================================================================
@@ -62,7 +70,7 @@ import {
62
70
  /**
63
71
  * A React hook for using a self-contained, immutable state machine within a component.
64
72
  * It provides a more ergonomic API than a raw dispatcher by returning a stable `actions`
65
- * object, similar to the `createRunner` primitive.
73
+ * object, similar to the `runMachine` primitive.
66
74
  *
67
75
  * This is the ideal hook for managing component-level state.
68
76
  *
@@ -75,57 +83,33 @@ import {
75
83
  * for type-narrowing.
76
84
  * - `actions`: A stable object containing all possible transition methods,
77
85
  * pre-bound to update the machine's state.
78
- *
79
- * @example
80
- * ```tsx
81
- * const [machine, actions] = useMachine(() => createCounterMachine({ count: 0 }));
82
- *
83
- * return (
84
- * <div>
85
- * <p>Count: {machine.context.count}</p>
86
- * <button onClick={() => actions.increment()}>Increment</button>
87
- * <button onClick={() => actions.add(5)}>Add 5</button>
88
- * </div>
89
- * );
90
- * ```
91
- *
92
- * @example With Type-State Programming
93
- * ```tsx
94
- * const [auth, actions] = useMachine(() => createLoggedOutMachine());
95
- *
96
- * return (
97
- * <div>
98
- * {auth.context.status === 'loggedOut' && (
99
- * <button onClick={() => actions.login('user')}>Login</button>
100
- * )}
101
- * {auth.context.status === 'loggedIn' && (
102
- * <p>Welcome, {auth.context.username}!</p>
103
- * <button onClick={() => actions.logout()}>Logout</button>
104
- * )}
105
- * </div>
106
- * );
107
- * ```
108
86
  */
109
87
  export function useMachine<M extends Machine<any>>(
110
88
  machineFactory: () => M
111
- ): [M, Runner<M>['actions']] {
89
+ ): [M, Record<string, (...args: any[]) => void>] {
112
90
  // useState holds the machine state, triggering re-renders.
113
- // The factory is passed directly to useState to ensure it's only called once.
114
91
  const [machine, setMachine] = useState(machineFactory);
115
92
 
116
93
  // useMemo creates a stable runner instance that survives re-renders.
117
- // The runner's job is to hold the *current* machine state and update our
118
- // React state when a transition occurs.
119
94
  const runner = useMemo(
120
- () => createRunner(machine, (newState) => {
121
- // This is the magic link: when the runner's internal state changes,
122
- // we update React's state, causing a re-render.
95
+ () => runMachine(machine, (newState) => {
123
96
  setMachine(newState);
124
97
  }),
125
- [] // Empty dependency array ensures the runner is created only once.
98
+ []
126
99
  );
127
100
 
128
- return [machine, runner.actions];
101
+ // Create a stable actions object that proxies calls to the dispatcher
102
+ const actions = useMemo(() => {
103
+ return new Proxy({} as any, {
104
+ get: (_target, prop) => {
105
+ return (...args: any[]) => {
106
+ runner.dispatch({ type: prop as any, args: args as any } as any);
107
+ };
108
+ }
109
+ });
110
+ }, [runner]);
111
+
112
+ return [machine, actions];
129
113
  }
130
114
 
131
115
  // =============================================================================
@@ -149,21 +133,6 @@ export function useMachine<M extends Machine<any>>(
149
133
  * values. Defaults to `Object.is` for strict equality checking. Provide your own
150
134
  * for deep comparisons of objects or arrays.
151
135
  * @returns The selected, memoized value from the machine's state.
152
- *
153
- * @example
154
- * ```tsx
155
- * // In parent component:
156
- * const [machine, actions] = useMachine(() => createUserMachine());
157
- *
158
- * // In child component (only re-renders when the user's name changes):
159
- * function UserNameDisplay({ machine }) {
160
- * const userName = useMachineSelector(
161
- * machine,
162
- * (m) => m.context.user.name
163
- * );
164
- * return <p>User: {userName}</p>;
165
- * }
166
- * ```
167
136
  */
168
137
  export function useMachineSelector<M extends Machine<any>, T>(
169
138
  machine: M,
@@ -172,7 +141,7 @@ export function useMachineSelector<M extends Machine<any>, T>(
172
141
  ): T {
173
142
  // Store the selected value in local state.
174
143
  const [selectedValue, setSelectedValue] = useState(() => selector(machine));
175
-
144
+
176
145
  // Keep refs to the latest selector and comparison functions.
177
146
  const selectorRef = useRef(selector);
178
147
  const isEqualRef = useRef(isEqual);
@@ -209,32 +178,6 @@ export function useMachineSelector<M extends Machine<any>, T>(
209
178
  * from the context.
210
179
  * @returns A stable `Ensemble` instance. The component will reactively update
211
180
  * when the ensemble's underlying context changes.
212
- *
213
- * @example
214
- * ```tsx
215
- * const fetchFactories = {
216
- * idle: (ctx) => createMachine(ctx, { fetch: () => ({ ...ctx, status: 'loading' }) }),
217
- * loading: (ctx) => createMachine(ctx, { succeed: (data) => ({ status: 'success', data }) }),
218
- * // ...
219
- * };
220
- *
221
- * function MyComponent() {
222
- * const ensemble = useEnsemble(
223
- * { status: 'idle', data: null },
224
- * fetchFactories,
225
- * (ctx) => ctx.status
226
- * );
227
- *
228
- * return (
229
- * <div>
230
- * <p>Status: {ensemble.context.status}</p>
231
- * {ensemble.state.context.status === 'idle' && (
232
- * <button onClick={() => ensemble.actions.fetch()}>Fetch</button>
233
- * )}
234
- * </div>
235
- * );
236
- * }
237
- * ```
238
181
  */
239
182
  export function useEnsemble<
240
183
  C extends object,
@@ -280,46 +223,9 @@ export function useEnsemble<
280
223
  *
281
224
  * It returns a `Provider` component and a suite of consumer hooks for accessing
282
225
  * the state and actions.
283
- *
284
- * @returns An object containing:
285
- * - `Provider`: The context provider component.
286
- * - `useMachineContext`: Hook to get the full `[machine, actions]` tuple.
287
- * - `useMachineState`: Hook to get only the reactive `machine` instance.
288
- * - `useMachineActions`: Hook to get only the stable `actions` object.
289
- * - `useSelector`: Hook to get a memoized slice of the machine's state.
290
- *
291
- * @example
292
- * ```tsx
293
- * // 1. Create the context
294
- * const { Provider, useMachineState, useMachineActions } = createMachineContext<MyMachine>();
295
- *
296
- * // 2. In your top-level component, create the machine and provide it
297
- * function App() {
298
- * const [machine, actions] = useMachine(() => createMyMachine());
299
- * return (
300
- * <Provider machine={machine} actions={actions}>
301
- * <ChildComponent />
302
- * </Provider>
303
- * );
304
- * }
305
- *
306
- * // 3. In a deeply nested child component
307
- * function ChildComponent() {
308
- * const machine = useMachineState(); // Gets the current state
309
- * const actions = useMachineActions(); // Gets the stable actions
310
- * const name = useSelector(m => m.context.name); // Selects a slice
311
- *
312
- * return (
313
- * <div>
314
- * <p>Name: {name}</p>
315
- * <button onClick={() => actions.rename('new name')}>Rename</button>
316
- * </div>
317
- * );
318
- * }
319
- * ```
320
226
  */
321
227
  export function createMachineContext<M extends Machine<any>>() {
322
- type MachineContextValue = [M, Runner<M>['actions']];
228
+ type MachineContextValue = [M, Record<string, (...args: any[]) => void>];
323
229
  const Context = createContext<MachineContextValue | null>(null);
324
230
 
325
231
  const Provider = ({
@@ -328,7 +234,7 @@ export function createMachineContext<M extends Machine<any>>() {
328
234
  children,
329
235
  }: {
330
236
  machine: M;
331
- actions: Runner<M>['actions'];
237
+ actions: Record<string, (...args: any[]) => void>;
332
238
  children: ReactNode;
333
239
  }) => {
334
240
  // Memoize the context value to prevent unnecessary re-renders in consumers.
@@ -345,7 +251,7 @@ export function createMachineContext<M extends Machine<any>>() {
345
251
  };
346
252
 
347
253
  const useMachineState = (): M => useMachineContext()[0];
348
- const useMachineActions = (): Runner<M>['actions'] => useMachineContext()[1];
254
+ const useMachineActions = (): Record<string, (...args: any[]) => void> => useMachineContext()[1];
349
255
 
350
256
  const useSelector = <T,>(
351
257
  selector: (state: M) => T,
@@ -362,4 +268,69 @@ export function createMachineContext<M extends Machine<any>>() {
362
268
  useMachineActions,
363
269
  useSelector,
364
270
  };
271
+ }
272
+
273
+ // =============================================================================
274
+ // HOOK 5: useActor (Actor Model)
275
+ // =============================================================================
276
+
277
+ /**
278
+ * Subscribes to an Actor and returns the current snapshot.
279
+ * Uses `useSyncExternalStore` for concurrent features compatibility.
280
+ *
281
+ * @param actor The actor instance to subscribe to.
282
+ * @returns The current machine snapshot.
283
+ */
284
+ export function useActor<M extends BaseMachine<any>>(actor: Actor<M>): M {
285
+ // bind is important if subscribe methods rely on `this`
286
+ const subscribe = useMemo(() => actor.subscribe.bind(actor), [actor]);
287
+ const getSnapshot = useMemo(() => actor.getSnapshot.bind(actor), [actor]);
288
+
289
+ return useSyncExternalStore(subscribe, getSnapshot);
290
+ }
291
+
292
+ /**
293
+ * Subscribes to an Actor and selects a slice of the state.
294
+ * Only re-renders when the selected slice changes.
295
+ *
296
+ * @param actor The actor instance.
297
+ * @param selector Function to select a part of the state.
298
+ * @param isEqual Optional equality function.
299
+ */
300
+ export function useActorSelector<M extends BaseMachine<any>, T>(
301
+ actor: Actor<M>,
302
+ selector: (state: M) => T,
303
+ isEqual: (a: T, b: T) => boolean = Object.is
304
+ ): T {
305
+ const subscribe = useMemo(() => actor.subscribe.bind(actor), [actor]);
306
+ const getSnapshot = useMemo(() => actor.getSnapshot.bind(actor), [actor]);
307
+
308
+ const getSelection = () => selector(getSnapshot());
309
+
310
+ const [selection, setSelection] = useState(getSelection);
311
+
312
+ // Custom selector logic since useSyncExternalStoreWithSelector is not available directly
313
+ // and we want to avoid extra deps.
314
+ // Actually, we can just use useSyncExternalStore and manage the selection stability,
315
+ // but useSyncExternalStore triggers if the result of getSnapshot changes (strict eq).
316
+ // If we wrap getSnapshot to return the selection, standard useSyncExternalStore handles it?
317
+ // No, useSyncExternalStore calls getSnapshot continuously during render to check for tearing.
318
+ // It needs to be cheap and consistent.
319
+
320
+ // Simple implementation: Subscribe and update local state only on change.
321
+ useEffect(() => {
322
+ const checkUpdate = () => {
323
+ const nextSelection = selector(actor.getSnapshot());
324
+ setSelection(prev => isEqual(prev, nextSelection) ? prev : nextSelection);
325
+ };
326
+
327
+ // Check immediately in case it changed between render and effect
328
+ checkUpdate();
329
+
330
+ return actor.subscribe(() => {
331
+ checkUpdate();
332
+ });
333
+ }, [actor, selector, isEqual]);
334
+
335
+ return selection;
365
336
  }