@doeixd/machine 0.0.4 → 0.0.6

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,66 @@ 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
+ // Prepend new items to preserve order (outer wraps first in call stack)
134
+ if (metadata.guards && existing.guards) {
135
+ merged.guards = [...metadata.guards, ...existing.guards];
136
+ } else if (metadata.guards) {
137
+ merged.guards = [...metadata.guards];
138
+ }
139
+
140
+ if (metadata.actions && existing.actions) {
141
+ merged.actions = [...metadata.actions, ...existing.actions];
142
+ } else if (metadata.actions) {
143
+ merged.actions = [...metadata.actions];
144
+ }
145
+
146
+ // Replace invoke entirely (not an array, can't merge)
147
+ // Last invoke wins (this matches XState semantics)
148
+
149
+ // Define or redefine the metadata property
150
+ Object.defineProperty(fn, RUNTIME_META, {
151
+ value: merged,
152
+ enumerable: false,
153
+ writable: false,
154
+ configurable: true // CRITICAL: Must be configurable for re-definition
155
+ });
156
+ }
90
157
 
91
158
  // =============================================================================
92
159
  // SECTION: ANNOTATION PRIMITIVES (THE DSL)
@@ -109,6 +176,11 @@ export function transitionTo<
109
176
  _target: T,
110
177
  implementation: F
111
178
  ): WithMeta<F, { target: T }> {
179
+ // Attach runtime metadata with class name
180
+ attachRuntimeMeta(implementation, {
181
+ target: _target.name || _target.toString()
182
+ });
183
+
112
184
  return implementation as any;
113
185
  }
114
186
 
@@ -127,6 +199,11 @@ export function describe<
127
199
  _text: string,
128
200
  transition: WithMeta<F, M>
129
201
  ): WithMeta<F, M & { description: string }> {
202
+ // Attach runtime metadata
203
+ attachRuntimeMeta(transition, {
204
+ description: _text
205
+ });
206
+
130
207
  return transition as any;
131
208
  }
132
209
 
@@ -146,6 +223,12 @@ export function guarded<
146
223
  guard: GuardMeta,
147
224
  transition: WithMeta<F, M>
148
225
  ): WithMeta<F, M & { guards: [typeof guard] }> {
226
+ // Attach runtime metadata
227
+ // Note: guards is an array, will be merged by attachRuntimeMeta
228
+ attachRuntimeMeta(transition, {
229
+ guards: [guard]
230
+ });
231
+
149
232
  return transition as any;
150
233
  }
151
234
 
@@ -168,6 +251,16 @@ export function invoke<
168
251
  service: { src: string; onDone: D; onError: E; description?: string },
169
252
  implementation: F
170
253
  ): WithMeta<F, { invoke: typeof service }> {
254
+ // Attach runtime metadata with class names resolved
255
+ attachRuntimeMeta(implementation, {
256
+ invoke: {
257
+ src: service.src,
258
+ onDone: service.onDone.name || service.onDone.toString(),
259
+ onError: service.onError.name || service.onError.toString(),
260
+ description: service.description
261
+ }
262
+ });
263
+
171
264
  return implementation as any;
172
265
  }
173
266
 
@@ -187,5 +280,47 @@ export function action<
187
280
  action: ActionMeta,
188
281
  transition: WithMeta<F, M>
189
282
  ): WithMeta<F, M & { actions: [typeof action] }> {
283
+ // Attach runtime metadata
284
+ // Note: actions is an array, will be merged by attachRuntimeMeta
285
+ attachRuntimeMeta(transition, {
286
+ actions: [action]
287
+ });
288
+
190
289
  return transition as any;
290
+ }
291
+
292
+ /**
293
+ * Flexible metadata wrapper for functional and type-state patterns.
294
+ *
295
+ * This function allows attaching metadata to values that don't use the class-based
296
+ * MachineBase pattern. It's particularly useful for:
297
+ * - Functional machines created with createMachine()
298
+ * - Type-state discriminated unions
299
+ * - Generic machine configurations
300
+ *
301
+ * @param meta - Partial metadata object describing states, transitions, etc.
302
+ * @param value - The value to annotate (machine, config, factory function, etc.)
303
+ * @returns The value unchanged (identity function at runtime)
304
+ *
305
+ * @example
306
+ * // Annotate a functional machine
307
+ * const machine = metadata(
308
+ * {
309
+ * target: IdleState,
310
+ * description: "Counter machine with increment/decrement"
311
+ * },
312
+ * createMachine({ count: 0 }, { ... })
313
+ * );
314
+ *
315
+ * @example
316
+ * // Annotate a factory function
317
+ * export const createCounter = metadata(
318
+ * { description: "Creates a counter starting at 0" },
319
+ * () => createMachine({ count: 0 }, { ... })
320
+ * );
321
+ */
322
+ export function metadata<T>(_meta: Partial<TransitionMeta>, value: T): T {
323
+ // At runtime, this is a no-op identity function
324
+ // At compile-time/static-analysis, the metadata can be extracted from the type signature
325
+ return value;
191
326
  }
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
  }