@pyreon/machine 0.5.0

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/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2025 Vit Bokisch
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/package.json ADDED
@@ -0,0 +1,50 @@
1
+ {
2
+ "name": "@pyreon/machine",
3
+ "version": "0.5.0",
4
+ "description": "Reactive state machines for Pyreon — constrained signals with type-safe transitions",
5
+ "license": "MIT",
6
+ "repository": {
7
+ "type": "git",
8
+ "url": "https://github.com/pyreon/fundamentals.git",
9
+ "directory": "packages/machine"
10
+ },
11
+ "homepage": "https://github.com/pyreon/fundamentals/tree/main/packages/machine#readme",
12
+ "bugs": {
13
+ "url": "https://github.com/pyreon/fundamentals/issues"
14
+ },
15
+ "publishConfig": {
16
+ "access": "public"
17
+ },
18
+ "files": [
19
+ "lib",
20
+ "src",
21
+ "README.md",
22
+ "LICENSE"
23
+ ],
24
+ "type": "module",
25
+ "main": "./lib/index.js",
26
+ "module": "./lib/index.js",
27
+ "types": "./lib/types/index.d.ts",
28
+ "exports": {
29
+ ".": {
30
+ "bun": "./src/index.ts",
31
+ "import": "./lib/index.js",
32
+ "types": "./lib/types/index.d.ts"
33
+ }
34
+ },
35
+ "sideEffects": false,
36
+ "scripts": {
37
+ "build": "vl_rolldown_build",
38
+ "dev": "vl_rolldown_build-watch",
39
+ "test": "vitest run",
40
+ "typecheck": "tsc --noEmit"
41
+ },
42
+ "peerDependencies": {
43
+ "@pyreon/reactivity": ">=0.5.0 <1.0.0"
44
+ },
45
+ "devDependencies": {
46
+ "@happy-dom/global-registrator": "^20.8.3",
47
+ "@pyreon/reactivity": ">=0.5.0 <1.0.0",
48
+ "@vitus-labs/tools-lint": "^1.11.0"
49
+ }
50
+ }
package/src/index.ts ADDED
@@ -0,0 +1,41 @@
1
+ /**
2
+ * @pyreon/machine — Reactive state machines for Pyreon.
3
+ *
4
+ * A machine is a constrained signal — it can only hold specific values
5
+ * and can only transition between them via specific events.
6
+ * Everything else (data, side effects, async) uses existing Pyreon primitives.
7
+ *
8
+ * @example
9
+ * ```tsx
10
+ * import { createMachine } from '@pyreon/machine'
11
+ *
12
+ * const machine = createMachine({
13
+ * initial: 'idle',
14
+ * states: {
15
+ * idle: { on: { FETCH: 'loading' } },
16
+ * loading: { on: { SUCCESS: 'done', ERROR: 'error' } },
17
+ * done: {},
18
+ * error: { on: { RETRY: 'loading' } },
19
+ * },
20
+ * })
21
+ *
22
+ * machine() // 'idle' — reads like a signal
23
+ * machine.send('FETCH') // transition
24
+ * {() => machine.matches('loading') && <Spinner />}
25
+ * ```
26
+ */
27
+
28
+ export { createMachine } from './machine'
29
+
30
+ // Types
31
+ export type {
32
+ EnterCallback,
33
+ InferEvents,
34
+ InferStates,
35
+ Machine,
36
+ MachineConfig,
37
+ MachineEvent,
38
+ StateConfig,
39
+ TransitionCallback,
40
+ TransitionConfig,
41
+ } from './types'
package/src/machine.ts ADDED
@@ -0,0 +1,166 @@
1
+ import { signal } from '@pyreon/reactivity'
2
+ import type {
3
+ EnterCallback,
4
+ InferEvents,
5
+ InferStates,
6
+ Machine,
7
+ MachineConfig,
8
+ MachineEvent,
9
+ TransitionCallback,
10
+ TransitionConfig,
11
+ } from './types'
12
+
13
+ /**
14
+ * Create a reactive state machine — a constrained signal with type-safe transitions.
15
+ *
16
+ * The returned instance is callable (reads like a signal) and exposes
17
+ * `send()`, `matches()`, `can()`, and listeners for state changes.
18
+ *
19
+ * @param config - Machine definition with initial state and state configs
20
+ * @returns A reactive machine instance
21
+ *
22
+ * @example
23
+ * ```tsx
24
+ * const machine = createMachine({
25
+ * initial: 'idle',
26
+ * states: {
27
+ * idle: { on: { FETCH: 'loading' } },
28
+ * loading: { on: { SUCCESS: 'done', ERROR: 'error' } },
29
+ * done: {},
30
+ * error: { on: { RETRY: 'loading' } },
31
+ * },
32
+ * })
33
+ *
34
+ * machine() // 'idle'
35
+ * machine.send('FETCH')
36
+ * machine() // 'loading'
37
+ *
38
+ * // Reactive in JSX
39
+ * {() => machine.matches('loading') && <Spinner />}
40
+ * ```
41
+ */
42
+ export function createMachine<
43
+ const TConfig extends MachineConfig<string, string>,
44
+ >(config: TConfig): Machine<InferStates<TConfig>, InferEvents<TConfig>> {
45
+ type TState = InferStates<TConfig>
46
+ type TEvent = InferEvents<TConfig>
47
+
48
+ const { initial, states } = config as unknown as MachineConfig<TState, TEvent>
49
+
50
+ // Validate initial state
51
+ if (!(initial in states)) {
52
+ throw new Error(
53
+ `[@pyreon/machine] Initial state '${initial}' is not defined in states`,
54
+ )
55
+ }
56
+
57
+ const current = signal<TState>(initial)
58
+ const enterListeners = new Map<TState, Set<EnterCallback<TEvent>>>()
59
+ const transitionListeners = new Set<TransitionCallback<TState, TEvent>>()
60
+
61
+ function resolveTransition(event: TEvent, payload?: unknown): TState | null {
62
+ const stateConfig = states[current.peek()]
63
+ if (!stateConfig?.on) return null
64
+
65
+ const transition = stateConfig.on[event] as
66
+ | TransitionConfig<TState>
67
+ | undefined
68
+ if (!transition) return null
69
+
70
+ if (typeof transition === 'string') {
71
+ return transition
72
+ }
73
+
74
+ // Guarded transition
75
+ if (transition.guard && !transition.guard(payload)) {
76
+ return null
77
+ }
78
+
79
+ return transition.target
80
+ }
81
+
82
+ // The machine instance — callable like a signal
83
+ function machine(): TState {
84
+ return current()
85
+ }
86
+
87
+ machine.send = (event: TEvent, payload?: unknown): void => {
88
+ const target = resolveTransition(event, payload)
89
+ if (target === null) return
90
+
91
+ const from = current.peek()
92
+ const machineEvent: MachineEvent<TEvent> = { type: event, payload }
93
+
94
+ current.set(target)
95
+
96
+ // Fire transition listeners
97
+ for (const cb of transitionListeners) {
98
+ cb(from, target, machineEvent)
99
+ }
100
+
101
+ // Fire enter listeners for the target state
102
+ const listeners = enterListeners.get(target)
103
+ if (listeners) {
104
+ for (const cb of listeners) {
105
+ cb(machineEvent)
106
+ }
107
+ }
108
+ }
109
+
110
+ machine.matches = (...matchStates: TState[]): boolean => {
111
+ const state = current()
112
+ return matchStates.includes(state)
113
+ }
114
+
115
+ machine.can = (event: TEvent): boolean => {
116
+ const stateConfig = states[current()]
117
+ if (!stateConfig?.on) return false
118
+
119
+ const transition = stateConfig.on[event]
120
+ if (!transition) return false
121
+
122
+ // For guarded transitions, we can't know without payload
123
+ // Return true if the event exists (guard may still reject)
124
+ return true
125
+ }
126
+
127
+ machine.nextEvents = (): TEvent[] => {
128
+ const stateConfig = states[current()]
129
+ if (!stateConfig?.on) return []
130
+ return Object.keys(stateConfig.on) as TEvent[]
131
+ }
132
+
133
+ machine.reset = (): void => {
134
+ current.set(initial)
135
+ }
136
+
137
+ machine.onEnter = (
138
+ state: TState,
139
+ callback: EnterCallback<TEvent>,
140
+ ): (() => void) => {
141
+ if (!enterListeners.has(state)) {
142
+ enterListeners.set(state, new Set())
143
+ }
144
+ enterListeners.get(state)!.add(callback)
145
+
146
+ return () => {
147
+ enterListeners.get(state)?.delete(callback)
148
+ }
149
+ }
150
+
151
+ machine.onTransition = (
152
+ callback: TransitionCallback<TState, TEvent>,
153
+ ): (() => void) => {
154
+ transitionListeners.add(callback)
155
+ return () => {
156
+ transitionListeners.delete(callback)
157
+ }
158
+ }
159
+
160
+ machine.dispose = (): void => {
161
+ enterListeners.clear()
162
+ transitionListeners.clear()
163
+ }
164
+
165
+ return machine as Machine<TState, TEvent>
166
+ }
@@ -0,0 +1,766 @@
1
+ import { computed, effect, signal } from '@pyreon/reactivity'
2
+ import { describe, expect, it, vi } from 'vitest'
3
+ import { createMachine } from '../index'
4
+
5
+ describe('createMachine', () => {
6
+ // ─── Basic state and transitions ─────────────────────────────────────
7
+
8
+ describe('basic transitions', () => {
9
+ it('starts in initial state', () => {
10
+ const m = createMachine({
11
+ initial: 'idle',
12
+ states: {
13
+ idle: { on: { START: 'running' } },
14
+ running: {},
15
+ },
16
+ })
17
+ expect(m()).toBe('idle')
18
+ })
19
+
20
+ it('transitions on valid event', () => {
21
+ const m = createMachine({
22
+ initial: 'idle',
23
+ states: {
24
+ idle: { on: { START: 'running' } },
25
+ running: { on: { STOP: 'idle' } },
26
+ },
27
+ })
28
+ m.send('START')
29
+ expect(m()).toBe('running')
30
+ })
31
+
32
+ it('ignores invalid events (no-op)', () => {
33
+ const m = createMachine({
34
+ initial: 'idle',
35
+ states: {
36
+ idle: { on: { START: 'running' } },
37
+ running: { on: { STOP: 'idle' } },
38
+ },
39
+ })
40
+ m.send('STOP' as any) // not valid in 'idle'
41
+ expect(m()).toBe('idle')
42
+ })
43
+
44
+ it('supports self-transitions', () => {
45
+ const m = createMachine({
46
+ initial: 'counting',
47
+ states: {
48
+ counting: { on: { INCREMENT: 'counting' } },
49
+ },
50
+ })
51
+ m.send('INCREMENT')
52
+ expect(m()).toBe('counting')
53
+ })
54
+
55
+ it('supports multiple transitions from one state', () => {
56
+ const m = createMachine({
57
+ initial: 'idle',
58
+ states: {
59
+ idle: { on: { FETCH: 'loading', CANCEL: 'cancelled' } },
60
+ loading: {},
61
+ cancelled: {},
62
+ },
63
+ })
64
+
65
+ m.send('CANCEL')
66
+ expect(m()).toBe('cancelled')
67
+ })
68
+
69
+ it('handles states with no transitions (final states)', () => {
70
+ const m = createMachine({
71
+ initial: 'idle',
72
+ states: {
73
+ idle: { on: { DONE: 'finished' } },
74
+ finished: {},
75
+ },
76
+ })
77
+ m.send('DONE')
78
+ expect(m()).toBe('finished')
79
+ m.send('DONE') // ignored — no transitions from 'finished'
80
+ expect(m()).toBe('finished')
81
+ })
82
+
83
+ it('throws on invalid initial state', () => {
84
+ expect(() =>
85
+ createMachine({
86
+ initial: 'nonexistent' as any,
87
+ states: {
88
+ idle: {},
89
+ },
90
+ }),
91
+ ).toThrow('[@pyreon/machine] Initial state')
92
+ })
93
+ })
94
+
95
+ // ─── Guards ──────────────────────────────────────────────────────────
96
+
97
+ describe('guards', () => {
98
+ it('transitions when guard returns true', () => {
99
+ const m = createMachine({
100
+ initial: 'editing',
101
+ states: {
102
+ editing: {
103
+ on: {
104
+ SUBMIT: { target: 'submitting', guard: () => true },
105
+ },
106
+ },
107
+ submitting: {},
108
+ },
109
+ })
110
+ m.send('SUBMIT')
111
+ expect(m()).toBe('submitting')
112
+ })
113
+
114
+ it('blocks transition when guard returns false', () => {
115
+ const m = createMachine({
116
+ initial: 'editing',
117
+ states: {
118
+ editing: {
119
+ on: {
120
+ SUBMIT: { target: 'submitting', guard: () => false },
121
+ },
122
+ },
123
+ submitting: {},
124
+ },
125
+ })
126
+ m.send('SUBMIT')
127
+ expect(m()).toBe('editing')
128
+ })
129
+
130
+ it('guard receives event payload', () => {
131
+ const guardFn = vi.fn((payload?: unknown) => {
132
+ return (payload as any)?.valid === true
133
+ })
134
+
135
+ const m = createMachine({
136
+ initial: 'editing',
137
+ states: {
138
+ editing: {
139
+ on: {
140
+ SUBMIT: { target: 'submitting', guard: guardFn },
141
+ },
142
+ },
143
+ submitting: {},
144
+ },
145
+ })
146
+
147
+ m.send('SUBMIT', { valid: false })
148
+ expect(m()).toBe('editing')
149
+ expect(guardFn).toHaveBeenCalledWith({ valid: false })
150
+
151
+ m.send('SUBMIT', { valid: true })
152
+ expect(m()).toBe('submitting')
153
+ })
154
+
155
+ it('guard with reactive signal', () => {
156
+ const isValid = signal(false)
157
+
158
+ const m = createMachine({
159
+ initial: 'editing',
160
+ states: {
161
+ editing: {
162
+ on: {
163
+ SUBMIT: {
164
+ target: 'submitting',
165
+ guard: () => isValid.peek(),
166
+ },
167
+ },
168
+ },
169
+ submitting: {},
170
+ },
171
+ })
172
+
173
+ m.send('SUBMIT')
174
+ expect(m()).toBe('editing')
175
+
176
+ isValid.set(true)
177
+ m.send('SUBMIT')
178
+ expect(m()).toBe('submitting')
179
+ })
180
+ })
181
+
182
+ // ─── matches ─────────────────────────────────────────────────────────
183
+
184
+ describe('matches()', () => {
185
+ it('returns true for current state', () => {
186
+ const m = createMachine({
187
+ initial: 'idle',
188
+ states: {
189
+ idle: { on: { START: 'running' } },
190
+ running: {},
191
+ },
192
+ })
193
+ expect(m.matches('idle')).toBe(true)
194
+ expect(m.matches('running')).toBe(false)
195
+ })
196
+
197
+ it('supports multiple states', () => {
198
+ const m = createMachine({
199
+ initial: 'loading',
200
+ states: {
201
+ idle: {},
202
+ loading: {},
203
+ error: {},
204
+ },
205
+ })
206
+ expect(m.matches('loading', 'error')).toBe(true)
207
+ expect(m.matches('idle', 'error')).toBe(false)
208
+ })
209
+
210
+ it('is reactive in effects', () => {
211
+ const m = createMachine({
212
+ initial: 'idle',
213
+ states: {
214
+ idle: { on: { START: 'running' } },
215
+ running: { on: { STOP: 'idle' } },
216
+ },
217
+ })
218
+ const results: boolean[] = []
219
+
220
+ effect(() => {
221
+ results.push(m.matches('running'))
222
+ })
223
+
224
+ expect(results).toEqual([false])
225
+
226
+ m.send('START')
227
+ expect(results).toEqual([false, true])
228
+
229
+ m.send('STOP')
230
+ expect(results).toEqual([false, true, false])
231
+ })
232
+ })
233
+
234
+ // ─── can ─────────────────────────────────────────────────────────────
235
+
236
+ describe('can()', () => {
237
+ it('returns true for valid events', () => {
238
+ const m = createMachine({
239
+ initial: 'idle',
240
+ states: {
241
+ idle: { on: { START: 'running' } },
242
+ running: { on: { STOP: 'idle' } },
243
+ },
244
+ })
245
+ expect(m.can('START')).toBe(true)
246
+ expect(m.can('STOP')).toBe(false)
247
+ })
248
+
249
+ it('is reactive', () => {
250
+ const m = createMachine({
251
+ initial: 'idle',
252
+ states: {
253
+ idle: { on: { START: 'running' } },
254
+ running: { on: { STOP: 'idle' } },
255
+ },
256
+ })
257
+ const results: boolean[] = []
258
+
259
+ effect(() => {
260
+ results.push(m.can('STOP'))
261
+ })
262
+
263
+ expect(results).toEqual([false])
264
+
265
+ m.send('START')
266
+ expect(results).toEqual([false, true])
267
+ })
268
+
269
+ it('returns true for guarded transitions (guard not evaluated)', () => {
270
+ const m = createMachine({
271
+ initial: 'editing',
272
+ states: {
273
+ editing: {
274
+ on: {
275
+ SUBMIT: { target: 'submitting', guard: () => false },
276
+ },
277
+ },
278
+ submitting: {},
279
+ },
280
+ })
281
+ // can() returns true because the event exists, even though guard would fail
282
+ expect(m.can('SUBMIT')).toBe(true)
283
+ })
284
+ })
285
+
286
+ // ─── nextEvents ──────────────────────────────────────────────────────
287
+
288
+ describe('nextEvents()', () => {
289
+ it('returns available events from current state', () => {
290
+ const m = createMachine({
291
+ initial: 'idle',
292
+ states: {
293
+ idle: { on: { FETCH: 'loading', RESET: 'idle' } },
294
+ loading: { on: { SUCCESS: 'done', ERROR: 'error' } },
295
+ done: {},
296
+ error: {},
297
+ },
298
+ })
299
+ expect(m.nextEvents()).toEqual(expect.arrayContaining(['FETCH', 'RESET']))
300
+ })
301
+
302
+ it('returns empty array for final states', () => {
303
+ const m = createMachine({
304
+ initial: 'idle',
305
+ states: {
306
+ idle: { on: { DONE: 'finished' } },
307
+ finished: {},
308
+ },
309
+ })
310
+ m.send('DONE')
311
+ expect(m.nextEvents()).toEqual([])
312
+ })
313
+
314
+ it('is reactive', () => {
315
+ const m = createMachine({
316
+ initial: 'idle',
317
+ states: {
318
+ idle: { on: { START: 'running' } },
319
+ running: { on: { STOP: 'idle', PAUSE: 'paused' } },
320
+ paused: { on: { RESUME: 'running' } },
321
+ },
322
+ })
323
+ const results: string[][] = []
324
+
325
+ effect(() => {
326
+ results.push(m.nextEvents())
327
+ })
328
+
329
+ m.send('START')
330
+ expect(results).toHaveLength(2)
331
+ expect(results[1]).toEqual(expect.arrayContaining(['STOP', 'PAUSE']))
332
+ })
333
+ })
334
+
335
+ // ─── reset ───────────────────────────────────────────────────────────
336
+
337
+ describe('reset()', () => {
338
+ it('returns to initial state', () => {
339
+ const m = createMachine({
340
+ initial: 'idle',
341
+ states: {
342
+ idle: { on: { START: 'running' } },
343
+ running: { on: { STOP: 'idle' } },
344
+ },
345
+ })
346
+ m.send('START')
347
+ expect(m()).toBe('running')
348
+
349
+ m.reset()
350
+ expect(m()).toBe('idle')
351
+ })
352
+ })
353
+
354
+ // ─── Reactivity ────────────────────────────────────────────────────
355
+
356
+ describe('reactivity', () => {
357
+ it('machine() is reactive in effect', () => {
358
+ const m = createMachine({
359
+ initial: 'a',
360
+ states: {
361
+ a: { on: { NEXT: 'b' } },
362
+ b: { on: { NEXT: 'c' } },
363
+ c: {},
364
+ },
365
+ })
366
+ const states: string[] = []
367
+
368
+ effect(() => {
369
+ states.push(m())
370
+ })
371
+
372
+ m.send('NEXT')
373
+ m.send('NEXT')
374
+
375
+ expect(states).toEqual(['a', 'b', 'c'])
376
+ })
377
+
378
+ it('machine() is reactive in computed', () => {
379
+ const m = createMachine({
380
+ initial: 'idle',
381
+ states: {
382
+ idle: { on: { LOAD: 'loading' } },
383
+ loading: { on: { DONE: 'idle' } },
384
+ },
385
+ })
386
+
387
+ const isLoading = computed(() => m() === 'loading')
388
+ expect(isLoading()).toBe(false)
389
+
390
+ m.send('LOAD')
391
+ expect(isLoading()).toBe(true)
392
+
393
+ m.send('DONE')
394
+ expect(isLoading()).toBe(false)
395
+ })
396
+ })
397
+
398
+ // ─── onEnter ─────────────────────────────────────────────────────────
399
+
400
+ describe('onEnter()', () => {
401
+ it('fires when entering a state', () => {
402
+ const m = createMachine({
403
+ initial: 'idle',
404
+ states: {
405
+ idle: { on: { LOAD: 'loading' } },
406
+ loading: { on: { DONE: 'idle' } },
407
+ },
408
+ })
409
+ const entered: string[] = []
410
+
411
+ m.onEnter('loading', (event) => {
412
+ entered.push(event.type)
413
+ })
414
+
415
+ m.send('LOAD')
416
+ expect(entered).toEqual(['LOAD'])
417
+ })
418
+
419
+ it('does not fire for other states', () => {
420
+ const m = createMachine({
421
+ initial: 'a',
422
+ states: {
423
+ a: { on: { GO: 'b' } },
424
+ b: { on: { GO: 'c' } },
425
+ c: {},
426
+ },
427
+ })
428
+ const fn = vi.fn()
429
+
430
+ m.onEnter('c', fn)
431
+ m.send('GO') // a → b
432
+ expect(fn).not.toHaveBeenCalled()
433
+
434
+ m.send('GO') // b → c
435
+ expect(fn).toHaveBeenCalledOnce()
436
+ })
437
+
438
+ it('receives event payload', () => {
439
+ const m = createMachine({
440
+ initial: 'idle',
441
+ states: {
442
+ idle: { on: { SELECT: 'selected' } },
443
+ selected: {},
444
+ },
445
+ })
446
+ let received: unknown = null
447
+
448
+ m.onEnter('selected', (event) => {
449
+ received = event.payload
450
+ })
451
+
452
+ m.send('SELECT', { id: 42 })
453
+ expect(received).toEqual({ id: 42 })
454
+ })
455
+
456
+ it('fires on self-transitions', () => {
457
+ const m = createMachine({
458
+ initial: 'counting',
459
+ states: {
460
+ counting: { on: { INC: 'counting' } },
461
+ },
462
+ })
463
+ const fn = vi.fn()
464
+
465
+ m.onEnter('counting', fn)
466
+ m.send('INC')
467
+ m.send('INC')
468
+
469
+ expect(fn).toHaveBeenCalledTimes(2)
470
+ })
471
+
472
+ it('returns unsubscribe function', () => {
473
+ const m = createMachine({
474
+ initial: 'a',
475
+ states: {
476
+ a: { on: { GO: 'b' } },
477
+ b: { on: { GO: 'a' } },
478
+ },
479
+ })
480
+ const fn = vi.fn()
481
+
482
+ const unsub = m.onEnter('b', fn)
483
+ m.send('GO') // a → b
484
+ expect(fn).toHaveBeenCalledOnce()
485
+
486
+ unsub()
487
+ m.send('GO') // b → a
488
+ m.send('GO') // a → b again
489
+ expect(fn).toHaveBeenCalledOnce() // not called again
490
+ })
491
+
492
+ it('multiple listeners for same state', () => {
493
+ const m = createMachine({
494
+ initial: 'idle',
495
+ states: {
496
+ idle: { on: { GO: 'active' } },
497
+ active: {},
498
+ },
499
+ })
500
+ const fn1 = vi.fn()
501
+ const fn2 = vi.fn()
502
+
503
+ m.onEnter('active', fn1)
504
+ m.onEnter('active', fn2)
505
+
506
+ m.send('GO')
507
+ expect(fn1).toHaveBeenCalledOnce()
508
+ expect(fn2).toHaveBeenCalledOnce()
509
+ })
510
+ })
511
+
512
+ // ─── onTransition ────────────────────────────────────────────────────
513
+
514
+ describe('onTransition()', () => {
515
+ it('fires on every transition', () => {
516
+ const m = createMachine({
517
+ initial: 'a',
518
+ states: {
519
+ a: { on: { NEXT: 'b' } },
520
+ b: { on: { NEXT: 'c' } },
521
+ c: {},
522
+ },
523
+ })
524
+ const transitions: [string, string, string][] = []
525
+
526
+ m.onTransition((from, to, event) => {
527
+ transitions.push([from, to, event.type])
528
+ })
529
+
530
+ m.send('NEXT')
531
+ m.send('NEXT')
532
+
533
+ expect(transitions).toEqual([
534
+ ['a', 'b', 'NEXT'],
535
+ ['b', 'c', 'NEXT'],
536
+ ])
537
+ })
538
+
539
+ it('does not fire when event is ignored', () => {
540
+ const m = createMachine({
541
+ initial: 'idle',
542
+ states: {
543
+ idle: { on: { START: 'running' } },
544
+ running: {},
545
+ },
546
+ })
547
+ const fn = vi.fn()
548
+
549
+ m.onTransition(fn)
550
+ m.send('STOP' as any) // invalid event
551
+ expect(fn).not.toHaveBeenCalled()
552
+ })
553
+
554
+ it('returns unsubscribe function', () => {
555
+ const m = createMachine({
556
+ initial: 'a',
557
+ states: {
558
+ a: { on: { GO: 'b' } },
559
+ b: { on: { GO: 'a' } },
560
+ },
561
+ })
562
+ const fn = vi.fn()
563
+
564
+ const unsub = m.onTransition(fn)
565
+ m.send('GO')
566
+ expect(fn).toHaveBeenCalledOnce()
567
+
568
+ unsub()
569
+ m.send('GO')
570
+ expect(fn).toHaveBeenCalledOnce() // not called again
571
+ })
572
+ })
573
+
574
+ // ─── dispose ─────────────────────────────────────────────────────────
575
+
576
+ describe('dispose()', () => {
577
+ it('removes all listeners', () => {
578
+ const m = createMachine({
579
+ initial: 'a',
580
+ states: {
581
+ a: { on: { GO: 'b' } },
582
+ b: { on: { GO: 'a' } },
583
+ },
584
+ })
585
+ const enterFn = vi.fn()
586
+ const transitionFn = vi.fn()
587
+
588
+ m.onEnter('b', enterFn)
589
+ m.onTransition(transitionFn)
590
+
591
+ m.dispose()
592
+
593
+ m.send('GO')
594
+ expect(enterFn).not.toHaveBeenCalled()
595
+ expect(transitionFn).not.toHaveBeenCalled()
596
+ })
597
+ })
598
+
599
+ // ─── Real-world patterns ───────────────────────────────────────────
600
+
601
+ describe('real-world patterns', () => {
602
+ it('multi-step wizard', () => {
603
+ const m = createMachine({
604
+ initial: 'step1',
605
+ states: {
606
+ step1: { on: { NEXT: 'step2' } },
607
+ step2: { on: { NEXT: 'step3', BACK: 'step1' } },
608
+ step3: { on: { SUBMIT: 'submitting', BACK: 'step2' } },
609
+ submitting: { on: { SUCCESS: 'done', ERROR: 'step3' } },
610
+ done: {},
611
+ },
612
+ })
613
+
614
+ m.send('NEXT') // step1 → step2
615
+ m.send('NEXT') // step2 → step3
616
+ expect(m()).toBe('step3')
617
+
618
+ m.send('BACK') // step3 → step2
619
+ expect(m()).toBe('step2')
620
+
621
+ m.send('NEXT') // step2 → step3
622
+ m.send('SUBMIT') // step3 → submitting
623
+ expect(m()).toBe('submitting')
624
+
625
+ m.send('SUCCESS')
626
+ expect(m()).toBe('done')
627
+
628
+ // Final state — no more transitions
629
+ m.send('SUBMIT')
630
+ expect(m()).toBe('done')
631
+ })
632
+
633
+ it('async fetch with onEnter', () => {
634
+ const m = createMachine({
635
+ initial: 'idle',
636
+ states: {
637
+ idle: { on: { FETCH: 'loading' } },
638
+ loading: { on: { SUCCESS: 'done', ERROR: 'error' } },
639
+ done: { on: { REFETCH: 'loading' } },
640
+ error: { on: { RETRY: 'loading' } },
641
+ },
642
+ })
643
+
644
+ const data = signal<string | null>(null)
645
+
646
+ m.onEnter('loading', () => {
647
+ // Simulate async — in real code this would be an API call
648
+ data.set('loaded data')
649
+ m.send('SUCCESS')
650
+ })
651
+
652
+ m.send('FETCH')
653
+ expect(m()).toBe('done')
654
+ expect(data()).toBe('loaded data')
655
+ })
656
+
657
+ it('toggle with reactive UI', () => {
658
+ const m = createMachine({
659
+ initial: 'off',
660
+ states: {
661
+ off: { on: { TOGGLE: 'on' } },
662
+ on: { on: { TOGGLE: 'off' } },
663
+ },
664
+ })
665
+
666
+ const labels: string[] = []
667
+ effect(() => {
668
+ labels.push(m.matches('on') ? 'ON' : 'OFF')
669
+ })
670
+
671
+ m.send('TOGGLE')
672
+ m.send('TOGGLE')
673
+ m.send('TOGGLE')
674
+
675
+ expect(labels).toEqual(['OFF', 'ON', 'OFF', 'ON'])
676
+ })
677
+
678
+ it('form with validation guard', () => {
679
+ const isValid = signal(false)
680
+
681
+ const m = createMachine({
682
+ initial: 'editing',
683
+ states: {
684
+ editing: {
685
+ on: {
686
+ SUBMIT: {
687
+ target: 'submitting',
688
+ guard: () => isValid.peek(),
689
+ },
690
+ },
691
+ },
692
+ submitting: { on: { SUCCESS: 'done', ERROR: 'editing' } },
693
+ done: {},
694
+ },
695
+ })
696
+
697
+ m.send('SUBMIT') // guard fails
698
+ expect(m()).toBe('editing')
699
+
700
+ isValid.set(true)
701
+ m.send('SUBMIT') // guard passes
702
+ expect(m()).toBe('submitting')
703
+ })
704
+
705
+ it('player with pause/resume', () => {
706
+ const m = createMachine({
707
+ initial: 'stopped',
708
+ states: {
709
+ stopped: { on: { PLAY: 'playing' } },
710
+ playing: { on: { PAUSE: 'paused', STOP: 'stopped' } },
711
+ paused: { on: { PLAY: 'playing', STOP: 'stopped' } },
712
+ },
713
+ })
714
+
715
+ m.send('PLAY')
716
+ expect(m()).toBe('playing')
717
+
718
+ m.send('PAUSE')
719
+ expect(m()).toBe('paused')
720
+
721
+ m.send('PLAY')
722
+ expect(m()).toBe('playing')
723
+
724
+ m.send('STOP')
725
+ expect(m()).toBe('stopped')
726
+ })
727
+
728
+ it('analytics tracking via onTransition', () => {
729
+ const m = createMachine({
730
+ initial: 'step1',
731
+ states: {
732
+ step1: { on: { NEXT: 'step2' } },
733
+ step2: { on: { NEXT: 'step3' } },
734
+ step3: {},
735
+ },
736
+ })
737
+
738
+ const tracked: string[] = []
739
+ m.onTransition((from, to) => {
740
+ tracked.push(`${from} → ${to}`)
741
+ })
742
+
743
+ m.send('NEXT')
744
+ m.send('NEXT')
745
+
746
+ expect(tracked).toEqual(['step1 → step2', 'step2 → step3'])
747
+ })
748
+
749
+ it('reusable machine definition', () => {
750
+ const toggleDef = {
751
+ initial: 'off' as const,
752
+ states: {
753
+ off: { on: { TOGGLE: 'on' as const } },
754
+ on: { on: { TOGGLE: 'off' as const } },
755
+ },
756
+ }
757
+
758
+ const m1 = createMachine(toggleDef)
759
+ const m2 = createMachine(toggleDef)
760
+
761
+ m1.send('TOGGLE')
762
+ expect(m1()).toBe('on')
763
+ expect(m2()).toBe('off') // independent instance
764
+ })
765
+ })
766
+ })
package/src/types.ts ADDED
@@ -0,0 +1,90 @@
1
+ /**
2
+ * A transition target — either a state name or an object with target and guard.
3
+ */
4
+ export type TransitionConfig<TState extends string> =
5
+ | TState
6
+ | { target: TState; guard: (payload?: unknown) => boolean }
7
+
8
+ /**
9
+ * State definition — maps event names to transition configs.
10
+ */
11
+ export interface StateConfig<TState extends string, TEvent extends string> {
12
+ on?: Partial<Record<TEvent, TransitionConfig<TState>>>
13
+ }
14
+
15
+ /**
16
+ * Machine definition — initial state and state configs.
17
+ */
18
+ export interface MachineConfig<TState extends string, TEvent extends string> {
19
+ initial: TState
20
+ states: Record<TState, StateConfig<TState, TEvent>>
21
+ }
22
+
23
+ /**
24
+ * Event object passed to listeners.
25
+ */
26
+ export interface MachineEvent<TEvent extends string = string> {
27
+ type: TEvent
28
+ payload?: unknown
29
+ }
30
+
31
+ /**
32
+ * Callback for onEnter — receives the event that caused the transition.
33
+ */
34
+ export type EnterCallback<TEvent extends string = string> = (
35
+ event: MachineEvent<TEvent>,
36
+ ) => void
37
+
38
+ /**
39
+ * Callback for onTransition — receives from state, to state, and the event.
40
+ */
41
+ export type TransitionCallback<
42
+ TState extends string = string,
43
+ TEvent extends string = string,
44
+ > = (from: TState, to: TState, event: MachineEvent<TEvent>) => void
45
+
46
+ /**
47
+ * The machine instance returned by `createMachine()`.
48
+ */
49
+ export interface Machine<TState extends string, TEvent extends string> {
50
+ /** Read current state — reactive in effects/computeds/JSX */
51
+ (): TState
52
+
53
+ /** Send an event to trigger a transition */
54
+ send: (event: TEvent, payload?: unknown) => void
55
+
56
+ /** Check if the machine is in one of the given states — reactive */
57
+ matches: (...states: TState[]) => boolean
58
+
59
+ /** Check if an event would trigger a valid transition from current state */
60
+ can: (event: TEvent) => boolean
61
+
62
+ /** Get all valid events from the current state — reactive */
63
+ nextEvents: () => TEvent[]
64
+
65
+ /** Reset to initial state */
66
+ reset: () => void
67
+
68
+ /** Register a callback for when the machine enters a specific state */
69
+ onEnter: (state: TState, callback: EnterCallback<TEvent>) => () => void
70
+
71
+ /** Register a callback for any state transition */
72
+ onTransition: (callback: TransitionCallback<TState, TEvent>) => () => void
73
+
74
+ /** Remove all listeners and clean up */
75
+ dispose: () => void
76
+ }
77
+
78
+ // ─── Type inference helpers ──────────────────────────────────────────────────
79
+
80
+ /** Extract state names from a machine config */
81
+ export type InferStates<T> = T extends { states: Record<infer S, unknown> }
82
+ ? S & string
83
+ : never
84
+
85
+ /** Extract event names from a machine config */
86
+ export type InferEvents<T> = T extends {
87
+ states: Record<string, { on?: Partial<Record<infer E, unknown>> }>
88
+ }
89
+ ? E & string
90
+ : never