@raphy-js/raph 1.0.0-dev.18 → 1.0.1-dev.20

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/README.md ADDED
@@ -0,0 +1,1096 @@
1
+ # Raph: from your first signal to a state machine
2
+
3
+ Raph has three layers that you can use independently: a reactive graph,
4
+ a runtime that owns and schedules work, and a state machine for processes
5
+ with explicit transitions. This guide builds them up in that order.
6
+
7
+ ```sh
8
+ npm install @raphy-js/raph@dev
9
+ ```
10
+
11
+ The examples are independent unless a section says otherwise. Short
12
+ fragments reuse names from their section. Imports use public package
13
+ entrypoints.
14
+
15
+ ```mermaid
16
+ flowchart LR
17
+ A["Reactivity<br/>signal, computed, effect"] --> B["Runtime<br/>ownership, lifecycle, scheduler"]
18
+ B --> C["Phases<br/>update, render, commit..."]
19
+ B --> D["Machine execution<br/>createMachine"]
20
+ E["Pure machine<br/>defineMachine, transition"] --> D
21
+ D --> F["Reactive snapshot"]
22
+ F --> C
23
+ ```
24
+
25
+ ## 1. Standalone reactivity
26
+
27
+ Start with the low-level reactive graph:
28
+
29
+ ```ts
30
+ import {
31
+ signal,
32
+ computed,
33
+ effect,
34
+ watch,
35
+ batch,
36
+ untracked,
37
+ } from '@raphy-js/raph/reactivity'
38
+ ```
39
+
40
+ There is no runtime, ownership tree, phase scheduler, or state machine here.
41
+
42
+ ### 1.1. Signal
43
+
44
+ A signal holds a mutable reactive value:
45
+
46
+ ```ts
47
+ const counter = signal(0)
48
+
49
+ console.log(counter.get()) // 0
50
+ counter.set(1)
51
+ console.log(counter.get()) // 1
52
+ ```
53
+
54
+ Its main operations are `get()` (read and track a dependency), `peek()`
55
+ (read without tracking), and `set(value)` (write). Call the method to read
56
+ the value: `counter.get` is the function itself.
57
+
58
+ ### 1.2. Computed
59
+
60
+ `computed` derives a value from other reactive values:
61
+
62
+ ```ts
63
+ const counter = signal(2)
64
+ const doubled = computed(() => counter.get() * 2)
65
+
66
+ console.log(doubled.get()) // 4
67
+ counter.set(5)
68
+ console.log(doubled.get()) // 10
69
+ ```
70
+
71
+ It has no independent mutable state. Raph discovers its dependencies,
72
+ caches the result, and recomputes lazily when it is read again.
73
+
74
+ ```mermaid
75
+ flowchart LR
76
+ C["counter<br/>signal"] --> D["doubled<br/>computed"]
77
+ ```
78
+
79
+ ### 1.3. Effect
80
+
81
+ An effect reacts to values read while it runs:
82
+
83
+ ```ts
84
+ const counter = signal(0)
85
+
86
+ const stop = effect(() => {
87
+ console.log('counter:', counter.get())
88
+ })
89
+
90
+ // Immediately: counter: 0
91
+ counter.set(1) // counter: 1
92
+ counter.set(2) // counter: 2
93
+
94
+ stop()
95
+ counter.set(3) // No further effect run
96
+ ```
97
+
98
+ The call to `counter.get()` establishes the dependency. An effect may
99
+ return cleanup, which runs before its next execution and when stopped:
100
+
101
+ ```ts
102
+ const stop = effect(() => {
103
+ const value = counter.get()
104
+ console.log('start', value)
105
+
106
+ return () => console.log('cleanup', value)
107
+ })
108
+ ```
109
+
110
+ ### 1.4. A complete reactive graph
111
+
112
+ ```ts
113
+ const c = signal(0)
114
+ const d = computed(() => c.get() * 2)
115
+
116
+ const stop = effect(() => {
117
+ console.log({ c: c.get(), d: d.get() })
118
+ })
119
+ ```
120
+
121
+ The first effect run creates three edges:
122
+
123
+ ```mermaid
124
+ flowchart LR
125
+ C["C: signal"] -->|E1| D["D: computed"]
126
+ D -->|E2| E["E: effect"]
127
+ C -->|E3| E
128
+ ```
129
+
130
+ After `c.set(1)`, `d` becomes stale and the effect is scheduled. When the
131
+ effect reads `d.get()`, it recomputes and logs `{ c: 1, d: 2 }`.
132
+
133
+ ### 1.5. Dynamic dependencies
134
+
135
+ Dependencies can change between effect runs:
136
+
137
+ ```ts
138
+ const enabled = signal(true)
139
+ const first = signal(10)
140
+ const second = signal(20)
141
+
142
+ effect(() => {
143
+ console.log(enabled.get() ? first.get() : second.get())
144
+ })
145
+ ```
146
+
147
+ When `enabled` is true, the effect depends on `enabled` and `first`.
148
+ When it becomes false, the edge to `first` is removed and an edge to
149
+ `second` is added.
150
+
151
+ ### 1.6. Watch
152
+
153
+ Use `watch` when you need the previous and current values of an explicit
154
+ source:
155
+
156
+ ```ts
157
+ const counter = signal(0)
158
+
159
+ const stop = watch(
160
+ counter,
161
+ (value, previous) => console.log({ previous, value }),
162
+ { immediate: true },
163
+ )
164
+
165
+ counter.set(1)
166
+ counter.set(2)
167
+ ```
168
+
169
+ An `effect` tracks everything it reads. A `watch` observes the source you
170
+ provide; reads inside its callback do not add dependencies.
171
+
172
+ ### 1.7. Batch
173
+
174
+ `batch` groups writes into one reactive boundary:
175
+
176
+ ```ts
177
+ const firstName = signal('John')
178
+ const lastName = signal('Smith')
179
+ const fullName = computed(() => `${firstName.get()} ${lastName.get()}`)
180
+
181
+ effect(() => console.log(fullName.get()))
182
+
183
+ batch(() => {
184
+ firstName.set('Jane')
185
+ lastName.set('Doe')
186
+ })
187
+ ```
188
+
189
+ The effect sees `Jane Doe`, rather than the intermediate `Jane Smith`.
190
+ `batch` does not provide rollback: writes already made remain if its
191
+ callback throws.
192
+
193
+ ### 1.8. Untracked
194
+
195
+ `untracked` reads a value without creating a dependency:
196
+
197
+ ```ts
198
+ effect(() => {
199
+ const reactiveValue = counter.get()
200
+ const informationalValue = untracked(() => someOtherSignal.get())
201
+ console.log(reactiveValue, informationalValue)
202
+ })
203
+ ```
204
+
205
+ This effect depends on `counter`, but not `someOtherSignal`.
206
+
207
+ ## 2. Add a runtime
208
+
209
+ Standalone reactivity does not know who owns a signal, when to dispose an
210
+ effect, or which phase should deliver it. A runtime supplies those rules:
211
+
212
+ ```ts
213
+ import {
214
+ RaphRuntime,
215
+ RaphSchedulerType,
216
+ } from '@raphy-js/raph/runtime'
217
+
218
+ const runtime = new RaphRuntime({
219
+ id: 'application',
220
+ scheduler: RaphSchedulerType.Microtask,
221
+ })
222
+ ```
223
+
224
+ `Sync` flushes in the current call stack, `Microtask` in the next
225
+ microtask, and `AnimationFrame` in an animation frame.
226
+
227
+ ### 2.1. Phases
228
+
229
+ Define phases before registering effects:
230
+
231
+ ```ts
232
+ runtime.definePhases([
233
+ { name: 'update' },
234
+ { name: 'render' },
235
+ ])
236
+ ```
237
+
238
+ They run in array order: `update -> render`. A phase is a named work queue,
239
+ not a callback you must supply. Without `process`, the runtime runs its
240
+ queued tasks automatically.
241
+
242
+ ### 2.2. Nodes and ownership
243
+
244
+ ```ts
245
+ const counterNode = runtime.node({ id: 'counter' })
246
+ const counter = counterNode.signal(0)
247
+
248
+ counterNode.effect('render', () => {
249
+ console.log('render counter:', counter.get())
250
+ })
251
+ ```
252
+
253
+ The node owns its signal and effect. `counterNode.dispose()` releases
254
+ owned effects, watchers, signals, computed values, machines, custom
255
+ cleanup, and child nodes. With standalone reactivity, you normally keep
256
+ each disposer yourself.
257
+
258
+ ### 2.3. What a signal write does
259
+
260
+ `counter.set(1)` invalidates the graph. It queues a persistent phase task,
261
+ and the scheduler requests a flush:
262
+
263
+ ```mermaid
264
+ sequenceDiagram
265
+ participant Code
266
+ participant Signal
267
+ participant Graph
268
+ participant Phase
269
+ participant Scheduler
270
+ participant Effect
271
+
272
+ Code->>Signal: set(1)
273
+ Signal->>Graph: invalidate
274
+ Graph->>Phase: enqueue persistent task
275
+ Phase->>Scheduler: request flush
276
+ Scheduler->>Phase: begin render
277
+ Phase->>Effect: task.run()
278
+ Effect->>Signal: get()
279
+ ```
280
+
281
+ With `Microtask`, the effect does not run inside `set()`:
282
+
283
+ ```ts
284
+ counter.set(1)
285
+ console.log('This runs before the render effect')
286
+ await runtime.afterFlush()
287
+ console.log('All scheduled phases have finished')
288
+ ```
289
+
290
+ `Sync` may flush in the same call stack, but effects still pass through
291
+ phase tasks.
292
+
293
+ ### 2.4. Complete runtime example
294
+
295
+ ```ts
296
+ import {
297
+ RaphRuntime,
298
+ RaphSchedulerType,
299
+ } from '@raphy-js/raph/runtime'
300
+
301
+ async function main() {
302
+ const runtime = new RaphRuntime({
303
+ id: 'counter-app',
304
+ scheduler: RaphSchedulerType.Microtask,
305
+ })
306
+
307
+ runtime.definePhases([
308
+ { name: 'update' },
309
+ { name: 'render' },
310
+ ])
311
+
312
+ const counterNode = runtime.node({ id: 'counter' })
313
+ const counter = counterNode.signal(0)
314
+ const doubled = counterNode.computed(() => counter.get() * 2)
315
+
316
+ counterNode.effect('render', () => {
317
+ console.log({ counter: counter.get(), doubled: doubled.get() })
318
+ })
319
+
320
+ await runtime.afterFlush() // Initial effect delivery
321
+
322
+ counter.set(1)
323
+ counter.set(2)
324
+ await runtime.afterFlush() // Both writes coalesce into one phase delivery
325
+
326
+ runtime.dispose()
327
+ }
328
+
329
+ void main()
330
+ ```
331
+
332
+ ### 2.5. Custom phase processing
333
+
334
+ The default phase runner calls `task.run()` for each task. Define
335
+ `process` only when the phase needs to control delivery:
336
+
337
+ ```ts
338
+ runtime.definePhases([
339
+ {
340
+ name: 'render',
341
+ process(batch) {
342
+ console.log('frame:', batch.frame.frame)
343
+ for (const task of batch.tasks) task.run()
344
+ },
345
+ },
346
+ ])
347
+ ```
348
+
349
+ A custom processor must resolve every task with `run()` (now), `defer()`
350
+ (next scheduler turn), or `cancel()` (discard this delivery). Leaving a
351
+ task unresolved makes the runtime defer it and report
352
+ `RaphPhaseProcessError`. An implicit effect's initial task may not be
353
+ cancelled before its first dependency collection.
354
+
355
+ ### 2.6. Effects and watches in a runtime
356
+
357
+ An effect collects implicit dependencies:
358
+
359
+ ```ts
360
+ node.effect('render', () => console.log(nodeSignal.get()))
361
+ ```
362
+
363
+ A watch names its source explicitly:
364
+
365
+ ```ts
366
+ viewNode.watch(modelSignal, 'render', () => {
367
+ console.log('model changed:', modelSignal.peek())
368
+ })
369
+ ```
370
+
371
+ An effect may implicitly read producers owned by its own node, an
372
+ ancestor, or the runtime root scope. A sibling branch requires an
373
+ explicit `watch`:
374
+
375
+ ```text
376
+ root
377
+ ├── modelNode
378
+ │ └── signal
379
+ └── viewNode
380
+ └── watch(modelSignal)
381
+ ```
382
+
383
+ The task belongs to `viewNode`; the signal owned by `modelNode` causes it.
384
+
385
+ ### 2.7. Actions
386
+
387
+ An action is a reusable named operation run as `batch + untracked`:
388
+
389
+ ```ts
390
+ const increment = counterNode.action('increment', () => {
391
+ counter.set(counter.peek() + 1)
392
+ })
393
+
394
+ increment()
395
+ ```
396
+
397
+ When the runtime is created with `enforceActions: true`, writes are
398
+ allowed only inside an action or batch.
399
+
400
+ ## 3. Why use a state machine?
401
+
402
+ You could model a dialog with three signals:
403
+
404
+ ```ts
405
+ const isOpen = signal(false)
406
+ const isClosing = signal(false)
407
+ const hasOverlay = signal(false)
408
+ ```
409
+
410
+ That leaves room for combinations that make no sense, such as a closed
411
+ dialog that is still closing. A machine names the allowed states and the
412
+ events that move between them:
413
+
414
+ ```mermaid
415
+ stateDiagram-v2
416
+ [*] --> closed
417
+ closed --> open: OPEN
418
+ open --> closed: CLOSE
419
+ ```
420
+
421
+ A signal answers "what value is there?" A computed value derives another
422
+ value; an effect runs after change. A machine answers "which mode is this
423
+ process in, and which transitions are allowed?"
424
+
425
+ ## 4. Machine vocabulary
426
+
427
+ | Term | Meaning |
428
+ | --- | --- |
429
+ | Model | Compiled machine description |
430
+ | State | Allowed mode of the process |
431
+ | Event | External occurrence or intent |
432
+ | Transition | Current state's response to an event |
433
+ | Context | Data carried across states |
434
+ | Guard | Pure check that permits or rejects a transition |
435
+ | Reducer | Pure calculation of the next context |
436
+ | Snapshot | Immutable view of the machine state |
437
+ | Output | One-time side effect after commit |
438
+ | Resource | Side effect active for a state's lifetime |
439
+ | Task | Asynchronous work tied to a state |
440
+ | Delay | Timed transition driven by a `MachineClock` |
441
+ | Actor | Child machine with its own queue and lifecycle |
442
+
443
+ ### 4.1. State and context are different
444
+
445
+ ```text
446
+ state: open
447
+ context: {
448
+ opens: 4,
449
+ lastCloseReason: "escape"
450
+ }
451
+ ```
452
+
453
+ The state names the mode. The context stores data. A counter belongs in
454
+ context; it does not need states such as `openWithTwoPreviousOpens`.
455
+
456
+ ## 5. A pure machine without a runtime
457
+
458
+ Machine APIs live in their own entrypoint:
459
+
460
+ ```ts
461
+ import {
462
+ defineMachine,
463
+ getInitialSnapshot,
464
+ transition,
465
+ } from '@raphy-js/raph/machine'
466
+ ```
467
+
468
+ They are separate from the reactivity and runtime entrypoints.
469
+
470
+ ### 5.1. Define context and events
471
+
472
+ ```ts
473
+ interface DialogContext {
474
+ readonly opens: number
475
+ readonly lastCloseReason?: 'button' | 'escape'
476
+ }
477
+
478
+ type DialogEvent =
479
+ | { readonly type: 'OPEN' }
480
+ | {
481
+ readonly type: 'CLOSE'
482
+ readonly reason: 'button' | 'escape'
483
+ }
484
+ ```
485
+
486
+ ### 5.2. Define the model
487
+
488
+ ```ts
489
+ import {
490
+ defineMachine,
491
+ type MachineBindings,
492
+ } from '@raphy-js/raph/machine'
493
+
494
+ const dialogModel = defineMachine<DialogContext, DialogEvent>({
495
+ id: 'dialog',
496
+ context: { opens: 0 },
497
+ initial: 'closed',
498
+ states: {
499
+ closed: {
500
+ on: {
501
+ OPEN: {
502
+ target: 'open',
503
+ reduce: 'countOpen',
504
+ emit: 'opened',
505
+ },
506
+ },
507
+ },
508
+ open: {
509
+ tags: ['visible'],
510
+ on: {
511
+ CLOSE: {
512
+ target: 'closed',
513
+ reduce: 'rememberCloseReason',
514
+ },
515
+ },
516
+ },
517
+ },
518
+ })
519
+ ```
520
+
521
+ `defineMachine()` validates the description, compiles paths and
522
+ transitions, and returns an immutable `MachineModel`. It does not start
523
+ anything.
524
+
525
+ ### 5.3. Bind the named operations
526
+
527
+ The names in the model are connected to implementations separately:
528
+
529
+ ```ts
530
+ const dialogBindings: MachineBindings<
531
+ DialogContext,
532
+ DialogEvent,
533
+ void
534
+ > = {
535
+ reducers: {
536
+ countOpen: ({ context }) => ({
537
+ ...context,
538
+ opens: context.opens + 1,
539
+ }),
540
+
541
+ rememberCloseReason: ({ context, event }) => ({
542
+ ...context,
543
+ lastCloseReason:
544
+ event.type === 'CLOSE'
545
+ ? event.reason
546
+ : context.lastCloseReason,
547
+ }),
548
+ },
549
+
550
+ outputs: {
551
+ opened: ({ snapshot }) => {
552
+ console.log('Dialog committed:', snapshot.active)
553
+ },
554
+ },
555
+ }
556
+ ```
557
+
558
+ A reducer computes `next context` from context, event, and input. It
559
+ should not mutate the DOM, fetch data, write a signal, or start a timer.
560
+ Use outputs, resources, or tasks for those effects.
561
+
562
+ ### 5.4. Get the initial snapshot
563
+
564
+ ```ts
565
+ let snapshot = getInitialSnapshot(dialogModel)
566
+ console.log(snapshot)
567
+ ```
568
+
569
+ In simplified form:
570
+
571
+ ```ts
572
+ {
573
+ active: ['closed'],
574
+ context: { opens: 0 },
575
+ history: {},
576
+ revision: 0,
577
+ status: 'active',
578
+ }
579
+ ```
580
+
581
+ Snapshots are immutable; do not edit them by hand.
582
+
583
+ ### 5.5. Run a pure transition
584
+
585
+ ```ts
586
+ snapshot = transition(
587
+ dialogModel,
588
+ snapshot,
589
+ { type: 'OPEN' },
590
+ dialogBindings,
591
+ )
592
+
593
+ console.log(snapshot.active) // ['open']
594
+ console.log(snapshot.context.opens) // 1
595
+ console.log(snapshot.revision) // 1
596
+ ```
597
+
598
+ `transition()` calculates the next snapshot. It does not run outputs,
599
+ resources, timers, or tasks, so the `opened` output above does not log.
600
+ This pure path is useful for transition analysis, deterministic replay,
601
+ SSR, and tests without a runtime.
602
+
603
+ ### 5.6. Ignored events
604
+
605
+ The `open` state has no handler for another `OPEN`:
606
+
607
+ ```ts
608
+ const sameSnapshot = transition(
609
+ dialogModel,
610
+ snapshot,
611
+ { type: 'OPEN' },
612
+ dialogBindings,
613
+ )
614
+
615
+ console.log(sameSnapshot === snapshot) // true
616
+ ```
617
+
618
+ An ignored event returns the same snapshot object.
619
+
620
+ ## 6. A live machine inside a runtime
621
+
622
+ `createMachine` connects the pure model to runtime execution:
623
+
624
+ ```ts
625
+ import { createMachine } from '@raphy-js/raph/machine'
626
+
627
+ const machine = createMachine(
628
+ dialogNode,
629
+ dialogModel,
630
+ dialogBindings,
631
+ ).start()
632
+ ```
633
+
634
+ The owner is a `RaphNode` or `RaphScope`. A standalone reactive scope
635
+ does not provide the lifecycle and execution boundary a live machine
636
+ needs. The following example uses `dialogModel` and `dialogBindings`
637
+ from section 5.
638
+
639
+ ### 6.1. Complete live example
640
+
641
+ ```ts
642
+ import {
643
+ RaphRuntime,
644
+ RaphSchedulerType,
645
+ } from '@raphy-js/raph/runtime'
646
+ import { createMachine } from '@raphy-js/raph/machine'
647
+
648
+ async function main() {
649
+ const runtime = new RaphRuntime({
650
+ id: 'dialog-app',
651
+ scheduler: RaphSchedulerType.Microtask,
652
+ })
653
+
654
+ runtime.definePhases([
655
+ { name: 'update' },
656
+ { name: 'render' },
657
+ ])
658
+
659
+ const dialogNode = runtime.node({ id: 'dialog' })
660
+ const machine = createMachine(
661
+ dialogNode,
662
+ dialogModel,
663
+ dialogBindings,
664
+ ).start()
665
+
666
+ dialogNode.effect('render', () => {
667
+ // snapshot is a regular ReadonlySignal.
668
+ const snapshot = machine.snapshot.get()
669
+ console.log('render dialog', {
670
+ active: snapshot.active,
671
+ context: snapshot.context,
672
+ revision: snapshot.revision,
673
+ })
674
+ })
675
+
676
+ await runtime.afterFlush() // Initial render
677
+
678
+ const result = machine.send({ type: 'OPEN' })
679
+
680
+ // send() has already updated the snapshot synchronously.
681
+ console.log(result.status) // transitioned
682
+ console.log(machine.matches('open')) // true
683
+ console.log(machine.hasTag('visible')) // true
684
+
685
+ await runtime.afterFlush() // Deliver the render effect
686
+
687
+ machine.send({ type: 'CLOSE', reason: 'escape' })
688
+ await runtime.afterFlush()
689
+
690
+ console.log(
691
+ machine.snapshot.peek().context.lastCloseReason,
692
+ ) // escape
693
+
694
+ runtime.dispose()
695
+ }
696
+
697
+ void main()
698
+ ```
699
+
700
+ ### 6.2. What happens during `send()`
701
+
702
+ ```mermaid
703
+ sequenceDiagram
704
+ participant Caller
705
+ participant Machine
706
+ participant PureCore
707
+ participant Snapshot
708
+ participant Lifecycle
709
+ participant Phase
710
+
711
+ Caller->>Machine: send(OPEN)
712
+ Machine->>Machine: enqueue event in FIFO
713
+ Machine->>PureCore: guards + reducers + transition
714
+ PureCore-->>Machine: next snapshot + effect plan
715
+ Machine->>Snapshot: one commit inside runtime.batch
716
+ Machine->>Lifecycle: outputs/resources/timers/tasks
717
+ Snapshot->>Phase: invalidate render effect
718
+ Machine-->>Caller: MachineSendResult
719
+ Phase->>Phase: scheduler flush
720
+ Phase->>Snapshot: effect reads snapshot.get()
721
+ ```
722
+
723
+ Guards, reducers, transition calculation, snapshot commit, and machine
724
+ lifecycle work happen in the machine macrostep. Effects and explicit
725
+ watches that read `machine.snapshot` run through runtime phases. A phase
726
+ controls reactive runtime delivery; it is not the sole executor of
727
+ machine logic.
728
+
729
+ ## 7. Guards
730
+
731
+ A guard decides whether a transition is allowed:
732
+
733
+ ```ts
734
+ interface Context {
735
+ readonly canClose: boolean
736
+ }
737
+
738
+ type Event =
739
+ | { readonly type: 'OPEN' }
740
+ | { readonly type: 'CLOSE' }
741
+
742
+ const model = defineMachine<Context, Event>({
743
+ id: 'guarded-dialog',
744
+ context: { canClose: false },
745
+ initial: 'closed',
746
+ states: {
747
+ closed: {
748
+ on: { OPEN: 'open' },
749
+ },
750
+ open: {
751
+ on: {
752
+ CLOSE: {
753
+ target: 'closed',
754
+ guard: 'canClose',
755
+ },
756
+ },
757
+ },
758
+ },
759
+ })
760
+
761
+ const bindings = {
762
+ guards: {
763
+ canClose: ({ context }: { context: Readonly<Context> }) => {
764
+ return context.canClose
765
+ },
766
+ },
767
+ }
768
+ ```
769
+
770
+ When a guard returns `false`, Raph checks the next transition in
771
+ declaration order. If none matches, the event is ignored. Guards should
772
+ be pure.
773
+
774
+ ## 8. Outputs and resources
775
+
776
+ ### 8.1. Output
777
+
778
+ An output is one-time work after a successful commit. A transition can
779
+ name one:
780
+
781
+ ```ts
782
+ closed: {
783
+ on: {
784
+ OPEN: {
785
+ target: 'open',
786
+ emit: 'notifyOpened',
787
+ },
788
+ },
789
+ }
790
+ ```
791
+
792
+ Bind its implementation separately:
793
+
794
+ ```ts
795
+ outputs: {
796
+ notifyOpened: ({ event, snapshot }) => {
797
+ console.log(event.type)
798
+ console.log(snapshot.active) // Already ['open']
799
+ },
800
+ }
801
+ ```
802
+
803
+ The snapshot has already been published. An output error does not roll
804
+ back that commit.
805
+
806
+ ### 8.2. Resource
807
+
808
+ A resource lives while its state is active:
809
+
810
+ ```ts
811
+ open: {
812
+ resources: ['presence'],
813
+ on: { CLOSE: 'closed' },
814
+ }
815
+ ```
816
+
817
+ ```ts
818
+ resources: {
819
+ presence: ({ signal }) => {
820
+ console.log('open-state resource started')
821
+
822
+ signal.addEventListener('abort', () => {
823
+ console.log('resource aborted')
824
+ })
825
+
826
+ return () => console.log('open-state resource cleaned')
827
+ },
828
+ }
829
+ ```
830
+
831
+ Raph starts it when entering `open`. It cleans up when leaving `open`,
832
+ stopping the machine, disposing its owner, or disposing the runtime.
833
+
834
+ ## 9. Delays
835
+
836
+ A state may request a timed transition:
837
+
838
+ ```ts
839
+ visible: {
840
+ after: {
841
+ autoClose: 'hidden',
842
+ },
843
+ }
844
+ ```
845
+
846
+ The binding supplies the duration:
847
+
848
+ ```ts
849
+ const machine = createMachine(owner, model, {
850
+ delays: { autoClose: 2_000 },
851
+ }).start()
852
+ ```
853
+
854
+ You can pass a custom `MachineClock` to test time without waiting for a
855
+ real timer.
856
+
857
+ ## 10. Asynchronous tasks
858
+
859
+ An invoked task belongs to the state that starts it:
860
+
861
+ ```ts
862
+ const loaderModel = defineMachine({
863
+ id: 'loader',
864
+ context: {},
865
+ initial: 'idle',
866
+ states: {
867
+ idle: {
868
+ on: { LOAD: 'loading' },
869
+ },
870
+ loading: {
871
+ invoke: {
872
+ src: 'loadData',
873
+ onDone: 'success',
874
+ onError: 'failed',
875
+ },
876
+ on: { CANCEL: 'idle' },
877
+ },
878
+ success: {},
879
+ failed: {},
880
+ },
881
+ })
882
+ ```
883
+
884
+ Bind the task at runtime:
885
+
886
+ ```ts
887
+ const loader = createMachine(owner, loaderModel, {
888
+ tasks: {
889
+ loadData: async ({ signal }) => {
890
+ const response = await fetch('/api/data', { signal })
891
+ return response.json()
892
+ },
893
+ },
894
+ }).start()
895
+ ```
896
+
897
+ If `loader.send({ type: 'CANCEL' })` leaves `loading`, the task is
898
+ aborted. A late result from that old task is ignored and cannot change
899
+ the current state.
900
+
901
+ ## 11. Nested states
902
+
903
+ Machines support compound states:
904
+
905
+ ```ts
906
+ const model = defineMachine({
907
+ id: 'dialog',
908
+ context: {},
909
+ initial: 'closed',
910
+ states: {
911
+ closed: {
912
+ on: { OPEN: 'open.idle' },
913
+ },
914
+ open: {
915
+ initial: 'idle',
916
+ states: {
917
+ idle: {
918
+ on: { CLOSE: 'open.closing' },
919
+ },
920
+ closing: {
921
+ always: 'closed',
922
+ },
923
+ },
924
+ },
925
+ },
926
+ })
927
+ ```
928
+
929
+ An active leaf path looks like `['open.idle']`. While there,
930
+ `machine.matches('open')` and `machine.matches('open.idle')` are both
931
+ true. Machines also support parallel states, shallow and deep history,
932
+ final states, `onDone`, `always`, `raise`, tags, metadata, and actors.
933
+
934
+ Within a macrostep, Raph stabilizes internal transitions. A limit of 100
935
+ microsteps protects against endless `always` or `raise` cycles.
936
+
937
+ ## 12. The event queue
938
+
939
+ If an output, task, or actor sends an event while another is being
940
+ processed, Raph puts it in one FIFO queue. It does not recursively run a
941
+ nested transition:
942
+
943
+ ```text
944
+ OPEN
945
+ ├── commit open
946
+ └── output sends FOCUS
947
+
948
+ FIFO:
949
+ finish OPEN
950
+ then process FOCUS
951
+ ```
952
+
953
+ This keeps ordering deterministic and avoids reentrant transitions.
954
+
955
+ ## 13. Lifecycle order
956
+
957
+ A transition performs lifecycle work in this order:
958
+
959
+ ```text
960
+ 1. Clean up resources, tasks, and timers for exited states
961
+ 2. Exit outputs
962
+ 3. Transition outputs
963
+ 4. Entry outputs
964
+ 5. Entry resources
965
+ 6. Timers and invokes
966
+ ```
967
+
968
+ Guards and reducers run before commit; outputs and resources run after
969
+ commit. A guard or reducer error prevents a new snapshot. An output or
970
+ resource error does not undo an already published snapshot.
971
+
972
+ ## 14. Persistence
973
+
974
+ Save a running machine with:
975
+
976
+ ```ts
977
+ const persisted = machine.persist()
978
+ ```
979
+
980
+ The persisted snapshot contains active states, context, history, status,
981
+ revision, final output, and the model ID and version. It does not save
982
+ live timers, promises, resources, actors, or the event queue.
983
+
984
+ Restore it with the same model and bindings:
985
+
986
+ ```ts
987
+ machine.stop()
988
+
989
+ const restored = createMachine(
990
+ owner,
991
+ dialogModel,
992
+ dialogBindings,
993
+ { persisted },
994
+ ).start()
995
+ ```
996
+
997
+ Restore does not replay old outputs. It restarts resources for active
998
+ states, restarts delays at their full duration, and creates tasks and
999
+ actors again. Migrations handle context format changes between model
1000
+ versions.
1001
+
1002
+ ## 15. Testing machines
1003
+
1004
+ Testing helpers have a separate entrypoint:
1005
+
1006
+ ```ts
1007
+ import {
1008
+ inspectMachine,
1009
+ enumerateMachinePaths,
1010
+ createMachineCoverage,
1011
+ createManualMachineClock,
1012
+ } from '@raphy-js/raph/machine/testing'
1013
+ ```
1014
+
1015
+ A manual clock makes timed transitions deterministic:
1016
+
1017
+ ```ts
1018
+ const clock = createManualMachineClock()
1019
+
1020
+ const machine = createMachine(
1021
+ owner,
1022
+ model,
1023
+ bindings,
1024
+ { clock },
1025
+ ).start()
1026
+
1027
+ clock.advance(1_999)
1028
+ console.log(machine.matches('visible')) // true
1029
+
1030
+ clock.advance(1)
1031
+ console.log(machine.matches('hidden')) // true
1032
+ ```
1033
+
1034
+ ## 16. Choosing the right layer
1035
+
1036
+ ```text
1037
+ Reactivity
1038
+ ├── signal mutable value
1039
+ ├── computed derived value
1040
+ ├── effect automatic reaction
1041
+ ├── watch explicit observation
1042
+ ├── batch grouped writes
1043
+ └── untracked read without a dependency
1044
+
1045
+ Runtime
1046
+ ├── RaphScope resource owner
1047
+ ├── RaphNode owner with a tree position
1048
+ ├── Scheduler decides when a flush starts
1049
+ ├── Phase named work queue
1050
+ ├── Task delivery of one phase effect
1051
+ ├── Watch explicit producer-to-consumer link
1052
+ ├── Action batch + untracked + diagnostics
1053
+ └── Disposal automatic cleanup of an ownership subtree
1054
+
1055
+ Pure machine core
1056
+ ├── Model
1057
+ ├── State
1058
+ ├── Event
1059
+ ├── Transition
1060
+ ├── Snapshot
1061
+ ├── Context
1062
+ ├── Guard
1063
+ └── Reducer
1064
+
1065
+ Machine runtime execution
1066
+ ├── createMachine(owner, model)
1067
+ ├── FIFO events
1068
+ ├── one snapshot commit per macrostep
1069
+ ├── outputs
1070
+ ├── resources
1071
+ ├── delays
1072
+ ├── tasks
1073
+ ├── actors
1074
+ ├── persistence
1075
+ └── reactive snapshot
1076
+ ```
1077
+
1078
+ Use a signal for a simple mutable value, a computed value for a
1079
+ derivation, and an effect for a reaction. Add a runtime when execution
1080
+ timing, local lifecycle, or cleanup matters. Use a machine for a process
1081
+ with discrete modes and allowed transitions. A simple button rarely
1082
+ needs one; a dialog, loader, connection lifecycle, or multistep workflow
1083
+ often does.
1084
+
1085
+ ## 17. Public entrypoints
1086
+
1087
+ | Import | Purpose |
1088
+ | --- | --- |
1089
+ | `@raphy-js/raph/reactivity` | Standalone reactive graph |
1090
+ | `@raphy-js/raph/runtime` | Ownership, nodes, phases, and scheduling |
1091
+ | `@raphy-js/raph/machine` | Pure machine model and runtime execution |
1092
+ | `@raphy-js/raph/machine/testing` | Inspection, coverage, and manual clock |
1093
+
1094
+ The complete Raph package also exposes paths and other runtime APIs.
1095
+ This guide follows the signal-to-machine route; each entrypoint exports
1096
+ its public TypeScript declarations.