@doeixd/machine 0.0.4 → 0.0.5

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/primitives.ts CHANGED
@@ -21,6 +21,13 @@
21
21
  */
22
22
  export const META_KEY = Symbol("MachineMeta");
23
23
 
24
+ /**
25
+ * Runtime metadata symbol.
26
+ * Non-enumerable property key for storing metadata on function objects at runtime.
27
+ * @internal
28
+ */
29
+ export const RUNTIME_META = Symbol('__machine_runtime_meta__');
30
+
24
31
  /**
25
32
  * Helper type representing a Class Constructor.
26
33
  * Used to reference target states by their class definition rather than magic strings.
@@ -87,6 +94,65 @@ export type WithMeta<
87
94
  M extends TransitionMeta
88
95
  > = F & { [META_KEY]: M };
89
96
 
97
+ // =============================================================================
98
+ // SECTION: RUNTIME METADATA ATTACHMENT
99
+ // =============================================================================
100
+
101
+ /**
102
+ * Runtime metadata interface (resolved class names as strings)
103
+ */
104
+ export interface RuntimeTransitionMeta {
105
+ target?: string;
106
+ description?: string;
107
+ guards?: Array<{ name: string; description?: string }>;
108
+ invoke?: {
109
+ src: string;
110
+ onDone: string;
111
+ onError: string;
112
+ description?: string;
113
+ };
114
+ actions?: Array<{ name: string; description?: string }>;
115
+ }
116
+
117
+ /**
118
+ * Attaches runtime metadata to a function object.
119
+ * Merges with existing metadata if present.
120
+ *
121
+ * @param fn - The function to attach metadata to
122
+ * @param metadata - Partial metadata to merge
123
+ * @internal
124
+ */
125
+ function attachRuntimeMeta(fn: any, metadata: Partial<RuntimeTransitionMeta>): void {
126
+ // Read existing metadata (may be undefined)
127
+ const existing = fn[RUNTIME_META] || {};
128
+
129
+ // Shallow merge for simple properties
130
+ const merged: any = { ...existing, ...metadata };
131
+
132
+ // Deep merge for array properties
133
+ if (metadata.guards && existing.guards) {
134
+ merged.guards = [...existing.guards, ...metadata.guards];
135
+ } else if (metadata.guards) {
136
+ merged.guards = [...metadata.guards];
137
+ }
138
+
139
+ if (metadata.actions && existing.actions) {
140
+ merged.actions = [...existing.actions, ...metadata.actions];
141
+ } else if (metadata.actions) {
142
+ merged.actions = [...metadata.actions];
143
+ }
144
+
145
+ // Replace invoke entirely (not an array, can't merge)
146
+ // Last invoke wins (this matches XState semantics)
147
+
148
+ // Define or redefine the metadata property
149
+ Object.defineProperty(fn, RUNTIME_META, {
150
+ value: merged,
151
+ enumerable: false,
152
+ writable: false,
153
+ configurable: true // CRITICAL: Must be configurable for re-definition
154
+ });
155
+ }
90
156
 
91
157
  // =============================================================================
92
158
  // SECTION: ANNOTATION PRIMITIVES (THE DSL)
@@ -109,6 +175,11 @@ export function transitionTo<
109
175
  _target: T,
110
176
  implementation: F
111
177
  ): WithMeta<F, { target: T }> {
178
+ // Attach runtime metadata with class name
179
+ attachRuntimeMeta(implementation, {
180
+ target: _target.name || _target.toString()
181
+ });
182
+
112
183
  return implementation as any;
113
184
  }
114
185
 
@@ -127,6 +198,11 @@ export function describe<
127
198
  _text: string,
128
199
  transition: WithMeta<F, M>
129
200
  ): WithMeta<F, M & { description: string }> {
201
+ // Attach runtime metadata
202
+ attachRuntimeMeta(transition, {
203
+ description: _text
204
+ });
205
+
130
206
  return transition as any;
131
207
  }
132
208
 
@@ -146,6 +222,12 @@ export function guarded<
146
222
  guard: GuardMeta,
147
223
  transition: WithMeta<F, M>
148
224
  ): WithMeta<F, M & { guards: [typeof guard] }> {
225
+ // Attach runtime metadata
226
+ // Note: guards is an array, will be merged by attachRuntimeMeta
227
+ attachRuntimeMeta(transition, {
228
+ guards: [guard]
229
+ });
230
+
149
231
  return transition as any;
150
232
  }
151
233
 
@@ -168,6 +250,16 @@ export function invoke<
168
250
  service: { src: string; onDone: D; onError: E; description?: string },
169
251
  implementation: F
170
252
  ): WithMeta<F, { invoke: typeof service }> {
253
+ // Attach runtime metadata with class names resolved
254
+ attachRuntimeMeta(implementation, {
255
+ invoke: {
256
+ src: service.src,
257
+ onDone: service.onDone.name || service.onDone.toString(),
258
+ onError: service.onError.name || service.onError.toString(),
259
+ description: service.description
260
+ }
261
+ });
262
+
171
263
  return implementation as any;
172
264
  }
173
265
 
@@ -187,5 +279,47 @@ export function action<
187
279
  action: ActionMeta,
188
280
  transition: WithMeta<F, M>
189
281
  ): WithMeta<F, M & { actions: [typeof action] }> {
282
+ // Attach runtime metadata
283
+ // Note: actions is an array, will be merged by attachRuntimeMeta
284
+ attachRuntimeMeta(transition, {
285
+ actions: [action]
286
+ });
287
+
190
288
  return transition as any;
289
+ }
290
+
291
+ /**
292
+ * Flexible metadata wrapper for functional and type-state patterns.
293
+ *
294
+ * This function allows attaching metadata to values that don't use the class-based
295
+ * MachineBase pattern. It's particularly useful for:
296
+ * - Functional machines created with createMachine()
297
+ * - Type-state discriminated unions
298
+ * - Generic machine configurations
299
+ *
300
+ * @param meta - Partial metadata object describing states, transitions, etc.
301
+ * @param value - The value to annotate (machine, config, factory function, etc.)
302
+ * @returns The value unchanged (identity function at runtime)
303
+ *
304
+ * @example
305
+ * // Annotate a functional machine
306
+ * const machine = metadata(
307
+ * {
308
+ * target: IdleState,
309
+ * description: "Counter machine with increment/decrement"
310
+ * },
311
+ * createMachine({ count: 0 }, { ... })
312
+ * );
313
+ *
314
+ * @example
315
+ * // Annotate a factory function
316
+ * export const createCounter = metadata(
317
+ * { description: "Creates a counter starting at 0" },
318
+ * () => createMachine({ count: 0 }, { ... })
319
+ * );
320
+ */
321
+ export function metadata<T>(_meta: Partial<TransitionMeta>, value: T): T {
322
+ // At runtime, this is a no-op identity function
323
+ // At compile-time/static-analysis, the metadata can be extracted from the type signature
324
+ return value;
191
325
  }
package/src/react.ts CHANGED
@@ -1,44 +1,365 @@
1
1
  /**
2
2
  * @file React integration for @doeixd/machine
3
- * @description Provides hooks for using state machines in React components
3
+ * @description
4
+ * Provides a suite of hooks for integrating state machines with React components,
5
+ * covering simple component state, performance-optimized selections, and advanced
6
+ * framework-agnostic patterns.
7
+ *
8
+ * ---
9
+ *
10
+ * ### Hooks Overview
11
+ *
12
+ * 1. **`useMachine(machineFactory)`**:
13
+ * - **Best for:** Local, self-contained component state.
14
+ * - **Returns:** `[machine, actions]`
15
+ * - The simplest way to get started. It manages an immutable machine instance
16
+ * and provides a stable `actions` object to trigger transitions.
17
+ *
18
+ * 2. **`useMachineSelector(machine, selector, isEqual?)`**:
19
+ * - **Best for:** Performance optimization in child components.
20
+ * - **Returns:** A selected slice of the machine's state: `T`.
21
+ * - Subscribes a component to only a part of the machine's state, preventing
22
+ * unnecessary re-renders when other parts of the context change.
23
+ *
24
+ * 3. **`useEnsemble(initialContext, factories, getDiscriminant)`**:
25
+ * - **Best for:** Complex state, shared state, or integrating with external logic.
26
+ * - **Returns:** A stable `Ensemble` instance.
27
+ * - The most powerful hook. It uses the `Ensemble` pattern to decouple your
28
+ * pure machine logic from React's state management, making your business
29
+ * logic portable and easy to test.
30
+ *
31
+ * 4. **`createMachineContext()`**:
32
+ * - **Best for:** Avoiding prop-drilling.
33
+ * - **Returns:** A `Provider` and consumer hooks (`useContext`, `useSelector`, etc.).
34
+ * - A utility to provide a machine created with `useMachine` or `useEnsemble` to
35
+ * the entire component tree below it.
4
36
  */
5
37
 
6
- import { useState, useRef, useEffect, useCallback } from 'react';
7
- import { runMachine, AsyncMachine, Event } from './index';
38
+ import {
39
+ useState,
40
+ useRef,
41
+ useEffect,
42
+ useMemo,
43
+ createContext,
44
+ useContext,
45
+ createElement,
46
+ type ReactNode,
47
+ } from 'react';
48
+
49
+ import {
50
+ Machine,
51
+ createRunner,
52
+ createEnsemble,
53
+ type Runner,
54
+ type Ensemble,
55
+ type StateStore,
56
+ } from './index';
57
+
58
+ // =============================================================================
59
+ // HOOK 1: useMachine (Ergonomic local state)
60
+ // =============================================================================
8
61
 
9
62
  /**
10
- * React hook for using an async state machine.
11
- * @template M - The async machine type
12
- * @param machineFactory - A function that creates the initial machine instance
13
- * @returns A tuple of [machine, dispatch] for state and event dispatching
63
+ * A React hook for using a self-contained, immutable state machine within a component.
64
+ * It provides a more ergonomic API than a raw dispatcher by returning a stable `actions`
65
+ * object, similar to the `createRunner` primitive.
66
+ *
67
+ * This is the ideal hook for managing component-level state.
68
+ *
69
+ * @template M - The machine type (can be a union of states).
70
+ * @param machineFactory - A function that creates the initial machine instance.
71
+ * This function is called only once on the initial render.
72
+ * @returns A tuple of `[machine, actions]`, where:
73
+ * - `machine`: The current, reactive machine instance. Its identity changes on
74
+ * every transition, triggering re-renders. Use this for reading state and
75
+ * for type-narrowing.
76
+ * - `actions`: A stable object containing all possible transition methods,
77
+ * pre-bound to update the machine's state.
14
78
  *
15
79
  * @example
16
- * const [machine, dispatch] = useMachine(() => createFetchingMachine());
80
+ * ```tsx
81
+ * const [machine, actions] = useMachine(() => createCounterMachine({ count: 0 }));
17
82
  *
18
- * // Dispatch events
19
- * dispatch({ type: 'fetchUser', args: [123] });
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
+ * ```
20
108
  */
21
- export function useMachine<M extends AsyncMachine<any>>(
109
+ export function useMachine<M extends Machine<any>>(
22
110
  machineFactory: () => M
23
- ): [M, (event: Event<M>) => Promise<M>] {
24
- // Use useState to hold the machine instance, triggering re-renders on change
111
+ ): [M, Runner<M>['actions']] {
112
+ // useState holds the machine state, triggering re-renders.
113
+ // The factory is passed directly to useState to ensure it's only called once.
25
114
  const [machine, setMachine] = useState(machineFactory);
26
115
 
27
- // Use a ref to hold the runner instance so it's stable across renders
28
- const runnerRef = useRef<ReturnType<typeof runMachine<any>> | null>(null);
116
+ // 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
+ 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.
123
+ setMachine(newState);
124
+ }),
125
+ [] // Empty dependency array ensures the runner is created only once.
126
+ );
127
+
128
+ return [machine, runner.actions];
129
+ }
29
130
 
30
- // Initialize the runner only once
131
+ // =============================================================================
132
+ // HOOK 2: useMachineSelector (Performance optimization)
133
+ // =============================================================================
134
+
135
+ /**
136
+ * A hook that subscribes a component to a selected slice of a machine's state.
137
+ *
138
+ * This is a critical performance optimization. It prevents a component from
139
+ * re-rendering if only an irrelevant part of the machine's context has changed.
140
+ * The component will only re-render if the value returned by the `selector` function
141
+ * is different from the previous render.
142
+ *
143
+ * @template M - The machine type.
144
+ * @template T - The type of the selected value.
145
+ * @param machine - The reactive machine instance from `useMachine`.
146
+ * @param selector - A function that takes the current machine state and returns
147
+ * a derived value.
148
+ * @param isEqual - An optional function to compare the previous and next selected
149
+ * values. Defaults to `Object.is` for strict equality checking. Provide your own
150
+ * for deep comparisons of objects or arrays.
151
+ * @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
+ */
168
+ export function useMachineSelector<M extends Machine<any>, T>(
169
+ machine: M,
170
+ selector: (state: M) => T,
171
+ isEqual: (a: T, b: T) => boolean = Object.is
172
+ ): T {
173
+ // Store the selected value in local state.
174
+ const [selectedValue, setSelectedValue] = useState(() => selector(machine));
175
+
176
+ // Keep refs to the latest selector and comparison functions.
177
+ const selectorRef = useRef(selector);
178
+ const isEqualRef = useRef(isEqual);
179
+ selectorRef.current = selector;
180
+ isEqualRef.current = isEqual;
181
+
182
+ // Effect to update the selected value only when it actually changes.
31
183
  useEffect(() => {
32
- runnerRef.current = runMachine(machine, (nextState) => {
33
- // The magic link: when the machine's state changes, update React's state.
34
- setMachine(nextState as M);
35
- });
36
- }, []); // Empty dependency array ensures this runs only on mount
37
-
38
- // Memoize the dispatch function so it has a stable identity
39
- const dispatch = useCallback((event: Event<M>) => {
40
- return runnerRef.current?.dispatch(event) || Promise.resolve(machine);
41
- }, [machine]);
42
-
43
- return [machine, dispatch];
184
+ const nextValue = selectorRef.current(machine);
185
+ if (!isEqualRef.current(selectedValue, nextValue)) {
186
+ setSelectedValue(nextValue);
187
+ }
188
+ }, [machine, selectedValue]); // Re-run only when the machine or the slice changes.
189
+
190
+ return selectedValue;
191
+ }
192
+
193
+ // =============================================================================
194
+ // HOOK 3: useEnsemble (Advanced integration pattern)
195
+ // =============================================================================
196
+
197
+ /**
198
+ * A hook that creates and manages an `Ensemble` within a React component.
199
+ *
200
+ * This is the most powerful and flexible integration pattern. It decouples your
201
+ * state logic (defined in `factories`) from React's state management. Your machine
202
+ * logic becomes pure, portable, and easily testable outside of React.
203
+ *
204
+ * @template C - The shared context object type.
205
+ * @template F - An object of factory functions that create machine instances.
206
+ * @param initialContext - The initial context object for the machine.
207
+ * @param factories - An object mapping state names to factory functions.
208
+ * @param getDiscriminant - An accessor function that determines the current state
209
+ * from the context.
210
+ * @returns A stable `Ensemble` instance. The component will reactively update
211
+ * 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
+ */
239
+ export function useEnsemble<
240
+ C extends object,
241
+ F extends Record<string, (context: C) => Machine<C>>
242
+ >(
243
+ initialContext: C,
244
+ factories: F,
245
+ getDiscriminant: (context: C) => keyof F
246
+ ): Ensemble<ReturnType<F[keyof F]>, C> {
247
+ const [context, setContext] = useState(initialContext);
248
+ const contextRef = useRef(context);
249
+ contextRef.current = context;
250
+
251
+ const store = useMemo<StateStore<C>>(
252
+ () => ({
253
+ // getContext reads from the ref to ensure it always has the latest value,
254
+ // avoiding stale closures.
255
+ getContext: () => contextRef.current,
256
+ setContext: (newContext) => {
257
+ // The update is dispatched to React's state setter.
258
+ setContext(newContext);
259
+ },
260
+ }),
261
+ [] // The store itself is stable and created only once.
262
+ );
263
+
264
+ // The ensemble instance is also memoized to remain stable across re-renders.
265
+ const ensemble = useMemo(
266
+ () => createEnsemble(store, factories, getDiscriminant),
267
+ [store, factories, getDiscriminant]
268
+ );
269
+
270
+ return ensemble;
271
+ }
272
+
273
+ // =============================================================================
274
+ // UTILITY 4: createMachineContext (Dependency injection)
275
+ // =============================================================================
276
+
277
+ /**
278
+ * Creates a React Context for providing a machine instance down the component tree,
279
+ * avoiding the need to pass it down as props ("prop-drilling").
280
+ *
281
+ * It returns a `Provider` component and a suite of consumer hooks for accessing
282
+ * 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
+ */
321
+ export function createMachineContext<M extends Machine<any>>() {
322
+ type MachineContextValue = [M, Runner<M>['actions']];
323
+ const Context = createContext<MachineContextValue | null>(null);
324
+
325
+ const Provider = ({
326
+ machine,
327
+ actions,
328
+ children,
329
+ }: {
330
+ machine: M;
331
+ actions: Runner<M>['actions'];
332
+ children: ReactNode;
333
+ }) => {
334
+ // Memoize the context value to prevent unnecessary re-renders in consumers.
335
+ const value = useMemo<MachineContextValue>(() => [machine, actions], [machine, actions]);
336
+ return createElement(Context.Provider, { value }, children);
337
+ };
338
+
339
+ const useMachineContext = (): MachineContextValue => {
340
+ const context = useContext(Context);
341
+ if (!context) {
342
+ throw new Error('useMachineContext must be used within a Machine.Provider');
343
+ }
344
+ return context;
345
+ };
346
+
347
+ const useMachineState = (): M => useMachineContext()[0];
348
+ const useMachineActions = (): Runner<M>['actions'] => useMachineContext()[1];
349
+
350
+ const useSelector = <T,>(
351
+ selector: (state: M) => T,
352
+ isEqual?: (a: T, b: T) => boolean
353
+ ): T => {
354
+ const machine = useMachineState();
355
+ return useMachineSelector(machine, selector, isEqual);
356
+ };
357
+
358
+ return {
359
+ Provider,
360
+ useMachineContext,
361
+ useMachineState,
362
+ useMachineActions,
363
+ useSelector,
364
+ };
44
365
  }