@modular-react/journeys 0.1.0 → 1.0.1
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 +21 -21
- package/README.md +2367 -1669
- package/dist/index.d.ts +686 -28
- package/dist/index.js +0 -0
- package/dist/index.js.map +1 -1
- package/dist/runtime-BUVl0_Ad.js +1422 -0
- package/dist/runtime-BUVl0_Ad.js.map +1 -0
- package/dist/testing.d.ts +155 -6
- package/dist/testing.js +62 -30
- package/dist/testing.js.map +1 -1
- package/package.json +7 -5
- package/dist/runtime-DyU_PmaC.js +0 -599
- package/dist/runtime-DyU_PmaC.js.map +0 -1
package/README.md
CHANGED
|
@@ -1,1669 +1,2367 @@
|
|
|
1
|
-
# @modular-react/journeys
|
|
2
|
-
|
|
3
|
-
Typed, serializable workflows that compose several modules. A journey declares how one module's exit feeds the next module's entry; the modules themselves stay journey-unaware
|
|
4
|
-
|
|
5
|
-
Use this package when a domain flow spans multiple modules with **shared state** (e.g. "confirm the customer's profile → branch into plan selection → collect a payment or activate a free trial"), and you want:
|
|
6
|
-
|
|
7
|
-
- typed end-to-end module boundaries,
|
|
8
|
-
- serializable state so a mid-flow reload or hand-off survives,
|
|
9
|
-
- a single place that owns transitions, instead of cross-cutting glue inside module stores.
|
|
10
|
-
|
|
11
|
-
Routes, slots, navigation, workspaces
|
|
12
|
-
|
|
13
|
-
## Prerequisite reading
|
|
14
|
-
|
|
15
|
-
- [Shell Patterns (Fundamentals)](../../docs/shell-patterns.md)
|
|
16
|
-
- [Workspace Patterns](../../docs/workspace-patterns.md)
|
|
17
|
-
|
|
18
|
-
## Contents
|
|
19
|
-
|
|
20
|
-
- [Installation](#installation)
|
|
21
|
-
- [Mental model](#mental-model)
|
|
22
|
-
- [Quickstart shortcut: scaffold the journey package](#quickstart-shortcut-scaffold-the-journey-package)
|
|
23
|
-
- [Quickstart](#quickstart)
|
|
24
|
-
- [Core concepts](#core-concepts)
|
|
25
|
-
- [Authoring patterns](#authoring-patterns)
|
|
26
|
-
- [Journey definition patterns](#journey-definition-patterns)
|
|
27
|
-
- [
|
|
28
|
-
- [
|
|
29
|
-
- [
|
|
30
|
-
- [
|
|
31
|
-
- [
|
|
32
|
-
- [
|
|
33
|
-
- [
|
|
34
|
-
- [
|
|
35
|
-
- [
|
|
36
|
-
- [
|
|
37
|
-
- [
|
|
38
|
-
- [
|
|
39
|
-
- [
|
|
40
|
-
- [
|
|
41
|
-
- [
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
```
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
|
62
|
-
|
|
|
63
|
-
| **
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
})
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
registry
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
}),
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
}
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
|
364
|
-
|
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
```ts
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
},
|
|
476
|
-
});
|
|
477
|
-
```
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
### Pattern
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
|
|
519
|
-
|
|
520
|
-
|
|
521
|
-
|
|
522
|
-
|
|
523
|
-
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
|
|
529
|
-
|
|
530
|
-
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
|
|
534
|
-
|
|
535
|
-
|
|
536
|
-
|
|
537
|
-
|
|
538
|
-
|
|
539
|
-
|
|
540
|
-
|
|
541
|
-
|
|
542
|
-
|
|
543
|
-
|
|
544
|
-
|
|
545
|
-
|
|
546
|
-
}
|
|
547
|
-
|
|
548
|
-
|
|
549
|
-
|
|
550
|
-
|
|
551
|
-
|
|
552
|
-
|
|
553
|
-
|
|
554
|
-
|
|
555
|
-
|
|
556
|
-
}
|
|
557
|
-
```
|
|
558
|
-
|
|
559
|
-
|
|
560
|
-
|
|
561
|
-
|
|
562
|
-
|
|
563
|
-
|
|
564
|
-
|
|
565
|
-
|
|
566
|
-
|
|
567
|
-
|
|
568
|
-
|
|
569
|
-
|
|
570
|
-
|
|
571
|
-
|
|
572
|
-
|
|
573
|
-
|
|
574
|
-
|
|
575
|
-
|
|
576
|
-
}
|
|
577
|
-
},
|
|
578
|
-
}
|
|
579
|
-
```
|
|
580
|
-
|
|
581
|
-
|
|
582
|
-
|
|
583
|
-
|
|
584
|
-
|
|
585
|
-
|
|
586
|
-
|
|
587
|
-
|
|
588
|
-
|
|
589
|
-
|
|
590
|
-
|
|
591
|
-
|
|
592
|
-
|
|
593
|
-
|
|
594
|
-
|
|
595
|
-
|
|
596
|
-
|
|
597
|
-
|
|
598
|
-
|
|
599
|
-
|
|
600
|
-
|
|
601
|
-
|
|
602
|
-
|
|
603
|
-
|
|
604
|
-
|
|
605
|
-
|
|
606
|
-
```
|
|
607
|
-
|
|
608
|
-
|
|
609
|
-
|
|
610
|
-
|
|
611
|
-
|
|
612
|
-
|
|
613
|
-
|
|
614
|
-
|
|
615
|
-
|
|
616
|
-
|
|
617
|
-
|
|
618
|
-
|
|
619
|
-
|
|
620
|
-
|
|
621
|
-
|
|
622
|
-
|
|
623
|
-
|
|
624
|
-
|
|
625
|
-
|
|
626
|
-
|
|
627
|
-
|
|
628
|
-
|
|
629
|
-
|
|
630
|
-
|
|
631
|
-
|
|
632
|
-
|
|
633
|
-
|
|
634
|
-
|
|
635
|
-
|
|
636
|
-
|
|
637
|
-
|
|
638
|
-
|
|
639
|
-
|
|
640
|
-
|
|
641
|
-
|
|
642
|
-
|
|
643
|
-
|
|
644
|
-
|
|
645
|
-
|
|
646
|
-
|
|
647
|
-
|
|
648
|
-
|
|
649
|
-
|
|
650
|
-
|
|
651
|
-
|
|
652
|
-
|
|
653
|
-
|
|
654
|
-
|
|
655
|
-
|
|
656
|
-
|
|
657
|
-
|
|
658
|
-
|
|
659
|
-
|
|
660
|
-
|
|
661
|
-
|
|
662
|
-
|
|
663
|
-
|
|
664
|
-
|
|
665
|
-
|
|
666
|
-
|
|
667
|
-
|
|
668
|
-
|
|
669
|
-
|
|
670
|
-
|
|
671
|
-
|
|
672
|
-
|
|
673
|
-
|
|
674
|
-
|
|
675
|
-
|
|
676
|
-
|
|
677
|
-
|
|
678
|
-
|
|
679
|
-
|
|
680
|
-
|
|
681
|
-
|
|
682
|
-
|
|
683
|
-
|
|
684
|
-
|
|
685
|
-
|
|
686
|
-
}
|
|
687
|
-
|
|
688
|
-
|
|
689
|
-
|
|
690
|
-
|
|
691
|
-
|
|
692
|
-
|
|
693
|
-
|
|
694
|
-
|
|
695
|
-
|
|
696
|
-
|
|
697
|
-
|
|
698
|
-
|
|
699
|
-
|
|
700
|
-
|
|
701
|
-
|
|
702
|
-
|
|
703
|
-
|
|
704
|
-
|
|
705
|
-
|
|
706
|
-
|
|
707
|
-
|
|
708
|
-
|
|
709
|
-
|
|
710
|
-
|
|
711
|
-
|
|
712
|
-
|
|
713
|
-
|
|
714
|
-
|
|
715
|
-
|
|
716
|
-
|
|
717
|
-
|
|
718
|
-
|
|
719
|
-
|
|
720
|
-
|
|
721
|
-
|
|
722
|
-
|
|
723
|
-
|
|
724
|
-
|
|
725
|
-
})
|
|
726
|
-
|
|
727
|
-
|
|
728
|
-
|
|
729
|
-
|
|
730
|
-
|
|
731
|
-
|
|
732
|
-
|
|
733
|
-
|
|
734
|
-
|
|
735
|
-
|
|
736
|
-
|
|
737
|
-
|
|
738
|
-
|
|
739
|
-
|
|
740
|
-
|
|
741
|
-
|
|
742
|
-
|
|
743
|
-
|
|
744
|
-
|
|
745
|
-
|
|
746
|
-
|
|
747
|
-
|
|
748
|
-
|
|
749
|
-
|
|
750
|
-
|
|
751
|
-
|
|
752
|
-
|
|
753
|
-
|
|
754
|
-
```
|
|
755
|
-
|
|
756
|
-
|
|
757
|
-
|
|
758
|
-
|
|
759
|
-
|
|
760
|
-
|
|
761
|
-
|
|
762
|
-
|
|
763
|
-
|
|
764
|
-
|
|
765
|
-
|
|
766
|
-
|
|
767
|
-
|
|
768
|
-
|
|
769
|
-
|
|
770
|
-
|
|
771
|
-
|
|
772
|
-
|
|
773
|
-
|
|
774
|
-
|
|
775
|
-
|
|
776
|
-
|
|
777
|
-
|
|
778
|
-
|
|
779
|
-
|
|
780
|
-
|
|
781
|
-
|
|
782
|
-
|
|
783
|
-
|
|
784
|
-
|
|
785
|
-
|
|
786
|
-
|
|
787
|
-
|
|
788
|
-
|
|
789
|
-
|
|
790
|
-
|
|
791
|
-
|
|
792
|
-
|
|
793
|
-
|
|
794
|
-
|
|
795
|
-
|
|
796
|
-
|
|
797
|
-
})
|
|
798
|
-
|
|
799
|
-
|
|
800
|
-
|
|
801
|
-
|
|
802
|
-
|
|
803
|
-
|
|
804
|
-
|
|
805
|
-
|
|
806
|
-
|
|
807
|
-
|
|
808
|
-
|
|
809
|
-
|
|
810
|
-
|
|
811
|
-
|
|
812
|
-
|
|
813
|
-
|
|
814
|
-
|
|
815
|
-
|
|
816
|
-
|
|
817
|
-
|
|
818
|
-
})
|
|
819
|
-
|
|
820
|
-
|
|
821
|
-
|
|
822
|
-
|
|
823
|
-
|
|
824
|
-
|
|
825
|
-
|
|
826
|
-
|
|
827
|
-
|
|
828
|
-
|
|
829
|
-
|
|
830
|
-
|
|
831
|
-
|
|
832
|
-
|
|
833
|
-
|
|
834
|
-
|
|
835
|
-
|
|
836
|
-
|
|
837
|
-
|
|
838
|
-
|
|
839
|
-
|
|
840
|
-
|
|
841
|
-
|
|
842
|
-
|
|
843
|
-
|
|
844
|
-
|
|
845
|
-
|
|
846
|
-
|
|
847
|
-
|
|
848
|
-
|
|
849
|
-
|
|
850
|
-
-
|
|
851
|
-
|
|
852
|
-
|
|
853
|
-
|
|
854
|
-
|
|
855
|
-
|
|
856
|
-
|
|
857
|
-
|
|
858
|
-
|
|
859
|
-
|
|
860
|
-
|
|
861
|
-
|
|
862
|
-
|
|
863
|
-
|
|
864
|
-
|
|
865
|
-
|
|
866
|
-
|
|
867
|
-
|
|
868
|
-
|
|
869
|
-
|
|
870
|
-
|
|
871
|
-
|
|
872
|
-
|
|
873
|
-
|
|
874
|
-
|
|
875
|
-
|
|
876
|
-
|
|
877
|
-
|
|
878
|
-
|
|
879
|
-
|
|
880
|
-
|
|
881
|
-
|
|
882
|
-
`
|
|
883
|
-
|
|
884
|
-
|
|
885
|
-
|
|
886
|
-
- `
|
|
887
|
-
|
|
888
|
-
|
|
889
|
-
|
|
890
|
-
|
|
891
|
-
|
|
892
|
-
|
|
893
|
-
|
|
894
|
-
|
|
895
|
-
|
|
896
|
-
|
|
897
|
-
|
|
898
|
-
|
|
899
|
-
|
|
900
|
-
|
|
901
|
-
|
|
902
|
-
|
|
903
|
-
|
|
904
|
-
|
|
905
|
-
|
|
906
|
-
|
|
907
|
-
|
|
908
|
-
|
|
909
|
-
|
|
910
|
-
|
|
911
|
-
|
|
912
|
-
|
|
913
|
-
|
|
914
|
-
|
|
915
|
-
|
|
916
|
-
|
|
917
|
-
|
|
918
|
-
|
|
919
|
-
|
|
920
|
-
|
|
921
|
-
|
|
922
|
-
|
|
923
|
-
|
|
924
|
-
|
|
925
|
-
|
|
926
|
-
}
|
|
927
|
-
|
|
928
|
-
|
|
929
|
-
|
|
930
|
-
|
|
931
|
-
|
|
932
|
-
|
|
933
|
-
|
|
934
|
-
|
|
935
|
-
|
|
936
|
-
|
|
937
|
-
|
|
938
|
-
|
|
939
|
-
|
|
940
|
-
|
|
941
|
-
|
|
942
|
-
|
|
943
|
-
|
|
944
|
-
|
|
945
|
-
|
|
946
|
-
|
|
947
|
-
|
|
948
|
-
|
|
949
|
-
|
|
950
|
-
|
|
951
|
-
|
|
952
|
-
|
|
953
|
-
|
|
954
|
-
|
|
955
|
-
|
|
956
|
-
|
|
957
|
-
|
|
958
|
-
|
|
959
|
-
|
|
960
|
-
|
|
961
|
-
|
|
962
|
-
|
|
963
|
-
|
|
964
|
-
|
|
965
|
-
|
|
966
|
-
|
|
967
|
-
|
|
968
|
-
|
|
969
|
-
|
|
970
|
-
|
|
971
|
-
|
|
972
|
-
|
|
973
|
-
|
|
974
|
-
|
|
975
|
-
|
|
976
|
-
|
|
977
|
-
|
|
978
|
-
|
|
979
|
-
|
|
980
|
-
|
|
981
|
-
|
|
982
|
-
|
|
983
|
-
|
|
984
|
-
|
|
985
|
-
|
|
986
|
-
|
|
987
|
-
|
|
988
|
-
|
|
989
|
-
|
|
990
|
-
|
|
991
|
-
|
|
992
|
-
|
|
993
|
-
|
|
994
|
-
|
|
995
|
-
|
|
996
|
-
|
|
997
|
-
|
|
998
|
-
|
|
999
|
-
|
|
1000
|
-
|
|
1001
|
-
}
|
|
1002
|
-
|
|
1003
|
-
|
|
1004
|
-
|
|
1005
|
-
|
|
1006
|
-
|
|
1007
|
-
|
|
1008
|
-
|
|
1009
|
-
|
|
1010
|
-
|
|
1011
|
-
|
|
1012
|
-
|
|
1013
|
-
|
|
1014
|
-
|
|
1015
|
-
|
|
1016
|
-
|
|
1017
|
-
|
|
1018
|
-
|
|
1019
|
-
|
|
1020
|
-
|
|
1021
|
-
|
|
1022
|
-
|
|
1023
|
-
|
|
1024
|
-
|
|
1025
|
-
|
|
1026
|
-
|
|
1027
|
-
|
|
1028
|
-
|
|
1029
|
-
|
|
1030
|
-
|
|
1031
|
-
|
|
1032
|
-
|
|
1033
|
-
|
|
1034
|
-
|
|
1035
|
-
|
|
1036
|
-
|
|
1037
|
-
|
|
1038
|
-
|
|
1039
|
-
|
|
1040
|
-
|
|
1041
|
-
|
|
1042
|
-
|
|
1043
|
-
|
|
1044
|
-
|
|
1045
|
-
|
|
1046
|
-
|
|
1047
|
-
|
|
1048
|
-
|
|
1049
|
-
|
|
1050
|
-
|
|
1051
|
-
|
|
1052
|
-
|
|
1053
|
-
|
|
1054
|
-
|
|
1055
|
-
|
|
1056
|
-
|
|
1057
|
-
|
|
1058
|
-
|
|
1059
|
-
|
|
1060
|
-
|
|
1061
|
-
|
|
1062
|
-
|
|
1063
|
-
|
|
1064
|
-
|
|
1065
|
-
|
|
1066
|
-
|
|
1067
|
-
|
|
1068
|
-
|
|
1069
|
-
|
|
1070
|
-
|
|
1071
|
-
|
|
1072
|
-
|
|
1073
|
-
|
|
1074
|
-
|
|
1075
|
-
|
|
1076
|
-
|
|
1077
|
-
|
|
1078
|
-
|
|
1079
|
-
|
|
1080
|
-
|
|
1081
|
-
|
|
1082
|
-
|
|
1083
|
-
|
|
1084
|
-
|
|
1085
|
-
|
|
1086
|
-
|
|
1087
|
-
|
|
1088
|
-
|
|
1089
|
-
|
|
1090
|
-
|
|
1091
|
-
|
|
1092
|
-
|
|
1093
|
-
|
|
1094
|
-
|
|
1095
|
-
|
|
1096
|
-
|
|
1097
|
-
|
|
1098
|
-
|
|
1099
|
-
|
|
1100
|
-
|
|
1101
|
-
|
|
1102
|
-
-
|
|
1103
|
-
|
|
1104
|
-
|
|
1105
|
-
|
|
1106
|
-
|
|
1107
|
-
|
|
1108
|
-
|
|
1109
|
-
|
|
1110
|
-
|
|
1111
|
-
|
|
1112
|
-
|
|
1113
|
-
|
|
1114
|
-
|
|
1115
|
-
|
|
1116
|
-
|
|
1117
|
-
|
|
1118
|
-
|
|
1119
|
-
|
|
1120
|
-
|
|
1121
|
-
|
|
1122
|
-
|
|
1123
|
-
|
|
1124
|
-
|
|
1125
|
-
|
|
1126
|
-
|
|
1127
|
-
|
|
1128
|
-
|
|
1129
|
-
|
|
1130
|
-
|
|
1131
|
-
|
|
1132
|
-
|
|
1133
|
-
|
|
1134
|
-
|
|
1135
|
-
|
|
1136
|
-
|
|
1137
|
-
|
|
1138
|
-
|
|
1139
|
-
|
|
1140
|
-
|
|
1141
|
-
|
|
1142
|
-
|
|
1143
|
-
|
|
1144
|
-
|
|
1145
|
-
|
|
1146
|
-
|
|
1147
|
-
|
|
1148
|
-
|
|
1149
|
-
|
|
1150
|
-
|
|
1151
|
-
|
|
1152
|
-
|
|
1153
|
-
|
|
1154
|
-
|
|
1155
|
-
|
|
1156
|
-
|
|
1157
|
-
|
|
1158
|
-
|
|
1159
|
-
|
|
1160
|
-
```
|
|
1161
|
-
|
|
1162
|
-
|
|
1163
|
-
|
|
1164
|
-
|
|
1165
|
-
|
|
1166
|
-
|
|
1167
|
-
|
|
1168
|
-
|
|
1169
|
-
|
|
1170
|
-
|
|
1171
|
-
|
|
1172
|
-
//
|
|
1173
|
-
|
|
1174
|
-
|
|
1175
|
-
|
|
1176
|
-
|
|
1177
|
-
|
|
1178
|
-
|
|
1179
|
-
|
|
1180
|
-
|
|
1181
|
-
|
|
1182
|
-
|
|
1183
|
-
|
|
1184
|
-
|
|
1185
|
-
|
|
1186
|
-
|
|
1187
|
-
|
|
1188
|
-
|
|
1189
|
-
|
|
1190
|
-
|
|
1191
|
-
|
|
1192
|
-
|
|
1193
|
-
|
|
1194
|
-
|
|
1195
|
-
|
|
1196
|
-
|
|
1197
|
-
|
|
1198
|
-
|
|
1199
|
-
|
|
1200
|
-
|
|
1201
|
-
|
|
1202
|
-
|
|
1203
|
-
|
|
1204
|
-
|
|
1205
|
-
|
|
1206
|
-
|
|
1207
|
-
|
|
1208
|
-
|
|
1209
|
-
|
|
1210
|
-
|
|
1211
|
-
}
|
|
1212
|
-
|
|
1213
|
-
|
|
1214
|
-
|
|
1215
|
-
|
|
1216
|
-
|
|
1217
|
-
|
|
1218
|
-
|
|
1219
|
-
|
|
1220
|
-
|
|
1221
|
-
|
|
1222
|
-
|
|
1223
|
-
|
|
1224
|
-
|
|
1225
|
-
|
|
1226
|
-
|
|
1227
|
-
|
|
1228
|
-
|
|
1229
|
-
|
|
1230
|
-
|
|
1231
|
-
|
|
1232
|
-
|
|
1233
|
-
|
|
1234
|
-
|
|
1235
|
-
|
|
1236
|
-
|
|
1237
|
-
|
|
1238
|
-
|
|
1239
|
-
|
|
1240
|
-
|
|
1241
|
-
|
|
1242
|
-
|
|
1243
|
-
|
|
1244
|
-
|
|
1245
|
-
|
|
1246
|
-
|
|
1247
|
-
|
|
1248
|
-
|
|
1249
|
-
|
|
1250
|
-
|
|
1251
|
-
|
|
1252
|
-
|
|
1253
|
-
|
|
1254
|
-
|
|
1255
|
-
|
|
1256
|
-
|
|
1257
|
-
|
|
1258
|
-
|
|
1259
|
-
|
|
1260
|
-
|
|
1261
|
-
|
|
1262
|
-
|
|
1263
|
-
|
|
1264
|
-
|
|
1265
|
-
|
|
1266
|
-
|
|
1267
|
-
|
|
1268
|
-
|
|
1269
|
-
|
|
1270
|
-
|
|
1271
|
-
|
|
1272
|
-
|
|
1273
|
-
|
|
1274
|
-
|
|
1275
|
-
|
|
1276
|
-
|
|
1277
|
-
|
|
1278
|
-
|
|
1279
|
-
|
|
1280
|
-
|
|
1281
|
-
|
|
1282
|
-
|
|
1283
|
-
|
|
1284
|
-
|
|
1285
|
-
|
|
1286
|
-
|
|
1287
|
-
|
|
1288
|
-
|
|
1289
|
-
|
|
1290
|
-
|
|
1291
|
-
|
|
1292
|
-
|
|
1293
|
-
|
|
1294
|
-
###
|
|
1295
|
-
|
|
1296
|
-
|
|
1297
|
-
|
|
1298
|
-
|
|
1299
|
-
|
|
1300
|
-
|
|
1301
|
-
|
|
1302
|
-
|
|
1303
|
-
|
|
1304
|
-
|
|
1305
|
-
|
|
1306
|
-
|
|
1307
|
-
|
|
1308
|
-
|
|
1309
|
-
|
|
1310
|
-
|
|
1311
|
-
|
|
1312
|
-
|
|
1313
|
-
|
|
1314
|
-
|
|
1315
|
-
|
|
1316
|
-
|
|
1317
|
-
```
|
|
1318
|
-
|
|
1319
|
-
|
|
1320
|
-
|
|
1321
|
-
|
|
1322
|
-
|
|
1323
|
-
|
|
1324
|
-
|
|
1325
|
-
|
|
1326
|
-
|
|
1327
|
-
|
|
1328
|
-
|
|
1329
|
-
|
|
1330
|
-
|
|
1331
|
-
|
|
1332
|
-
|
|
1333
|
-
|
|
1334
|
-
|
|
1335
|
-
|
|
1336
|
-
|
|
1337
|
-
|
|
1338
|
-
|
|
1339
|
-
|
|
1340
|
-
|
|
1341
|
-
|
|
1342
|
-
|
|
1343
|
-
|
|
1344
|
-
|
|
1345
|
-
|
|
1346
|
-
```
|
|
1347
|
-
|
|
1348
|
-
|
|
1349
|
-
|
|
1350
|
-
|
|
1351
|
-
|
|
1352
|
-
##
|
|
1353
|
-
|
|
1354
|
-
|
|
1355
|
-
|
|
1356
|
-
|
|
1357
|
-
|
|
1358
|
-
|
|
1359
|
-
|
|
1360
|
-
|
|
1361
|
-
|
|
1362
|
-
|
|
1363
|
-
|
|
1364
|
-
|
|
1365
|
-
|
|
1366
|
-
|
|
1367
|
-
|
|
1368
|
-
|
|
1369
|
-
|
|
1370
|
-
|
|
1371
|
-
|
|
1372
|
-
|
|
1373
|
-
|
|
1374
|
-
|
|
1375
|
-
|
|
1376
|
-
|
|
1377
|
-
|
|
1378
|
-
|
|
1379
|
-
|
|
1380
|
-
|
|
1381
|
-
|
|
1382
|
-
|
|
1383
|
-
|
|
1384
|
-
|
|
1385
|
-
|
|
1386
|
-
|
|
1387
|
-
|
|
1388
|
-
|
|
1389
|
-
|
|
1390
|
-
|
|
1391
|
-
|
|
1392
|
-
|
|
1393
|
-
|
|
1394
|
-
|
|
1395
|
-
|
|
1396
|
-
|
|
1397
|
-
|
|
1398
|
-
|
|
1399
|
-
|
|
1400
|
-
|
|
1401
|
-
|
|
1402
|
-
|
|
1403
|
-
|
|
1404
|
-
|
|
1405
|
-
|
|
1406
|
-
|
|
1407
|
-
|
|
1408
|
-
|
|
1409
|
-
|
|
1410
|
-
|
|
1411
|
-
The
|
|
1412
|
-
|
|
1413
|
-
|
|
1414
|
-
|
|
1415
|
-
|
|
1416
|
-
|
|
1417
|
-
|
|
1418
|
-
|
|
1419
|
-
|
|
1420
|
-
|
|
1421
|
-
|
|
1422
|
-
|
|
1423
|
-
|
|
1424
|
-
|
|
1425
|
-
|
|
1426
|
-
|
|
1427
|
-
|
|
1428
|
-
|
|
1429
|
-
|
|
1430
|
-
|
|
1431
|
-
|
|
1432
|
-
|
|
1433
|
-
|
|
1434
|
-
|
|
1435
|
-
|
|
1436
|
-
|
|
1437
|
-
|
|
1438
|
-
|
|
1439
|
-
|
|
1440
|
-
|
|
1441
|
-
|
|
1442
|
-
|
|
1443
|
-
|
|
1444
|
-
|
|
1445
|
-
|
|
1446
|
-
|
|
1447
|
-
|
|
1448
|
-
|
|
1449
|
-
|
|
1450
|
-
|
|
1451
|
-
|
|
1452
|
-
|
|
1453
|
-
|
|
1454
|
-
|
|
1455
|
-
|
|
1456
|
-
|
|
1457
|
-
|
|
1458
|
-
|
|
1459
|
-
|
|
1460
|
-
|
|
1461
|
-
|
|
1462
|
-
|
|
1463
|
-
|
|
1464
|
-
|
|
1465
|
-
|
|
1466
|
-
|
|
1467
|
-
|
|
1468
|
-
|
|
1469
|
-
|
|
1470
|
-
|
|
1471
|
-
|
|
1472
|
-
|
|
1473
|
-
|
|
1474
|
-
|
|
1475
|
-
|
|
1476
|
-
|
|
1477
|
-
|
|
1478
|
-
|
|
1479
|
-
|
|
1480
|
-
|
|
1481
|
-
|
|
1482
|
-
|
|
1483
|
-
|
|
|
1484
|
-
|
|
|
1485
|
-
|
|
|
1486
|
-
|
|
|
1487
|
-
|
|
|
1488
|
-
|
|
1489
|
-
|
|
1490
|
-
|
|
1491
|
-
|
|
1492
|
-
|
|
1493
|
-
|
|
1494
|
-
|
|
1495
|
-
|
|
1496
|
-
|
|
1497
|
-
|
|
1498
|
-
|
|
1499
|
-
|
|
1500
|
-
|
|
1501
|
-
|
|
1502
|
-
|
|
1503
|
-
|
|
1504
|
-
|
|
1505
|
-
|
|
1506
|
-
|
|
1507
|
-
|
|
1508
|
-
|
|
1509
|
-
|
|
1510
|
-
|
|
1511
|
-
|
|
1512
|
-
|
|
1513
|
-
|
|
1514
|
-
|
|
1515
|
-
|
|
1516
|
-
|
|
1517
|
-
|
|
1518
|
-
|
|
1519
|
-
|
|
1520
|
-
|
|
1521
|
-
|
|
1522
|
-
|
|
1523
|
-
|
|
1524
|
-
|
|
1525
|
-
|
|
1526
|
-
|
|
1527
|
-
|
|
1528
|
-
|
|
1529
|
-
|
|
1530
|
-
|
|
1531
|
-
|
|
1532
|
-
|
|
1533
|
-
|
|
1534
|
-
|
|
1535
|
-
|
|
1536
|
-
|
|
1537
|
-
|
|
1538
|
-
|
|
1539
|
-
|
|
1540
|
-
|
|
1541
|
-
|
|
1542
|
-
|
|
1543
|
-
|
|
1544
|
-
|
|
1545
|
-
|
|
1546
|
-
|
|
1547
|
-
|
|
1548
|
-
|
|
1549
|
-
|
|
1550
|
-
|
|
1551
|
-
|
|
1552
|
-
|
|
1553
|
-
|
|
1554
|
-
|
|
1555
|
-
|
|
1556
|
-
|
|
1557
|
-
|
|
1558
|
-
|
|
1559
|
-
|
|
1560
|
-
|
|
1561
|
-
|
|
1562
|
-
|
|
1563
|
-
|
|
1564
|
-
|
|
1565
|
-
|
|
1566
|
-
|
|
1567
|
-
|
|
1568
|
-
|
|
1569
|
-
|
|
1570
|
-
|
|
1571
|
-
|
|
1572
|
-
|
|
1573
|
-
|
|
1574
|
-
|
|
1575
|
-
|
|
1576
|
-
|
|
1577
|
-
/**
|
|
1578
|
-
*
|
|
1579
|
-
*
|
|
1580
|
-
*
|
|
1581
|
-
|
|
1582
|
-
|
|
1583
|
-
|
|
1584
|
-
|
|
1585
|
-
|
|
1586
|
-
|
|
1587
|
-
|
|
1588
|
-
|
|
1589
|
-
|
|
1590
|
-
|
|
1591
|
-
|
|
1592
|
-
|
|
1593
|
-
|
|
1594
|
-
|
|
1595
|
-
|
|
1596
|
-
|
|
1597
|
-
|
|
1598
|
-
|
|
1599
|
-
|
|
1600
|
-
|
|
1601
|
-
|
|
1602
|
-
|
|
1603
|
-
|
|
1604
|
-
|
|
1605
|
-
|
|
1606
|
-
|
|
1607
|
-
|
|
1608
|
-
|
|
1609
|
-
|
|
1610
|
-
|
|
1611
|
-
|
|
1612
|
-
|
|
1613
|
-
|
|
1614
|
-
|
|
1615
|
-
|
|
1616
|
-
|
|
1617
|
-
|
|
1618
|
-
|
|
1619
|
-
|
|
1620
|
-
|
|
1621
|
-
|
|
1622
|
-
|
|
1623
|
-
|
|
1624
|
-
|
|
1625
|
-
|
|
1626
|
-
|
|
1627
|
-
|
|
1628
|
-
|
|
1629
|
-
|
|
1630
|
-
|
|
1631
|
-
|
|
1632
|
-
|
|
1633
|
-
|
|
1634
|
-
|
|
1635
|
-
|
|
1636
|
-
|
|
1637
|
-
|
|
1638
|
-
|
|
1639
|
-
|
|
1640
|
-
|
|
1641
|
-
|
|
1642
|
-
|
|
1643
|
-
|
|
1644
|
-
|
|
1645
|
-
|
|
1646
|
-
|
|
1647
|
-
|
|
1648
|
-
|
|
1649
|
-
|
|
1650
|
-
|
|
1651
|
-
|
|
1652
|
-
|
|
1653
|
-
|
|
1654
|
-
|
|
1655
|
-
|
|
1656
|
-
|
|
1657
|
-
|
|
1658
|
-
|
|
1659
|
-
|
|
1660
|
-
|
|
1661
|
-
|
|
1662
|
-
|
|
1663
|
-
|
|
1664
|
-
|
|
1665
|
-
|
|
1666
|
-
|
|
1667
|
-
|
|
1668
|
-
|
|
1669
|
-
|
|
1
|
+
# @modular-react/journeys
|
|
2
|
+
|
|
3
|
+
Typed, serializable workflows that compose several modules. A journey declares how one module's exit feeds the next module's entry; the modules themselves stay journey-unaware - they just declare what input they accept and what outcomes they can emit.
|
|
4
|
+
|
|
5
|
+
Use this package when a domain flow spans multiple modules with **shared state** (e.g. "confirm the customer's profile → branch into plan selection → collect a payment or activate a free trial"), and you want:
|
|
6
|
+
|
|
7
|
+
- typed end-to-end module boundaries,
|
|
8
|
+
- serializable state so a mid-flow reload or hand-off survives,
|
|
9
|
+
- a single place that owns transitions, instead of cross-cutting glue inside module stores.
|
|
10
|
+
|
|
11
|
+
Routes, slots, navigation, workspaces - none of that changes. Journeys sit **on top** of the existing framework. Apps that don't register a journey incur nothing beyond the package being statically linked.
|
|
12
|
+
|
|
13
|
+
## Prerequisite reading
|
|
14
|
+
|
|
15
|
+
- [Shell Patterns (Fundamentals)](../../docs/shell-patterns.md)
|
|
16
|
+
- [Workspace Patterns](../../docs/workspace-patterns.md)
|
|
17
|
+
|
|
18
|
+
## Contents
|
|
19
|
+
|
|
20
|
+
- [Installation](#installation)
|
|
21
|
+
- [Mental model](#mental-model)
|
|
22
|
+
- [Quickstart shortcut: scaffold the journey package](#quickstart-shortcut-scaffold-the-journey-package) - `create journey` if you bootstrapped with the modular-react CLI
|
|
23
|
+
- [Quickstart](#quickstart) - the 5-step path from zero to a running journey
|
|
24
|
+
- [Core concepts](#core-concepts) - entries, exits, `allowBack`, lifecycle, statuses, keys
|
|
25
|
+
- [Authoring patterns](#authoring-patterns) - module entries, exits, loading flows, `goBack` opt-in, **lazy entry-points (code-splitting)**
|
|
26
|
+
- [Journey definition patterns](#journey-definition-patterns) - branching, `selectModule` dispatch, terminals, state rewrites, bounded history, module compatibility, **`defineTransition` (auto-preload + narrowed handler return)**
|
|
27
|
+
- [Composing journeys (invoke / resume)](#composing-journeys-invoke--resume) - call out to a child journey mid-flow and resume on its outcome
|
|
28
|
+
- [Cycle and recursion safety](#cycle-and-recursion-safety) - cycle / depth / undeclared-child / bounce-limit guards and how to tune them
|
|
29
|
+
- [Runtime surface](#runtime-surface) - the `JourneyRuntime` you get back from `manifest.journeys`
|
|
30
|
+
- [Journey handles](#journey-handles) - typed tokens for `runtime.start(handle, input)`
|
|
31
|
+
- [`JourneyProvider` + context](#journeyprovider--context)
|
|
32
|
+
- [Persistence](#persistence) - adapters, key design, save queue, hydrate vs start, versioning
|
|
33
|
+
- [Rendering - `JourneyOutlet`](#rendering--journeyoutlet) - props, error policies, host rules
|
|
34
|
+
- [Hosting plain modules - `ModuleTab`](#hosting-plain-modules--moduletab)
|
|
35
|
+
- [Observation hooks](#observation-hooks)
|
|
36
|
+
- [Testing](#testing) - module-level, pure simulator, integration, persistence adapters
|
|
37
|
+
- [Integration patterns](#integration-patterns) - tabs, modals, routes, wizards, command palette
|
|
38
|
+
- [Debugging](#debugging) - dev-mode warnings and introspection
|
|
39
|
+
- [Errors, races, and edge cases](#errors-races-and-edge-cases)
|
|
40
|
+
- [Limitations](#limitations)
|
|
41
|
+
- [TypeScript inference notes](#typescript-inference-notes)
|
|
42
|
+
- [API reference](#api-reference)
|
|
43
|
+
- [Example projects](#example-projects)
|
|
44
|
+
|
|
45
|
+
## Installation
|
|
46
|
+
|
|
47
|
+
The journey runtime is already a transitive dependency of `@react-router-modules/runtime` and `@tanstack-react-modules/runtime`. Install it directly only when the shell needs to type against journey types (usually it does):
|
|
48
|
+
|
|
49
|
+
```bash
|
|
50
|
+
pnpm add @modular-react/journeys
|
|
51
|
+
```
|
|
52
|
+
|
|
53
|
+
Peer deps: `@modular-react/core`, `@modular-react/react`, `react`, `react-dom`.
|
|
54
|
+
|
|
55
|
+
If you scaffolded your project with the modular-react CLI, you can scaffold a journey package the same way - see [§ Quickstart shortcut: scaffold the journey package](#quickstart-shortcut-scaffold-the-journey-package) below.
|
|
56
|
+
|
|
57
|
+
## Mental model
|
|
58
|
+
|
|
59
|
+
Three roles, strictly separated:
|
|
60
|
+
|
|
61
|
+
| Role | Owns | Does NOT know about |
|
|
62
|
+
| ----------- | ----------------------------------------------------------------------------------------------------------- | ---------------------------------------------------- |
|
|
63
|
+
| **Module** | Its entry components, input types, exit names, exit output types. | Journeys. Who opens it. What comes next. |
|
|
64
|
+
| **Journey** | The modules it composes (by type), transitions between entry/exit pairs, shared state. | Shell. Tabs. Routes. |
|
|
65
|
+
| **Shell** | Registering modules + journeys, mounting `<JourneyOutlet>` inside its container (tab, route, modal, panel). | Any specific journey's logic, state, or transitions. |
|
|
66
|
+
|
|
67
|
+
## Quickstart shortcut: scaffold the journey package
|
|
68
|
+
|
|
69
|
+
If you used the modular-react CLI to bootstrap your project, you can skip writing the journey package boilerplate by hand. Run:
|
|
70
|
+
|
|
71
|
+
```bash
|
|
72
|
+
# React Router
|
|
73
|
+
npx @react-router-modules/cli create journey customer-onboarding \
|
|
74
|
+
--modules profile,plan,billing --persistence
|
|
75
|
+
|
|
76
|
+
# TanStack Router
|
|
77
|
+
npx @tanstack-react-modules/cli create journey customer-onboarding \
|
|
78
|
+
--modules profile,plan,billing --persistence
|
|
79
|
+
```
|
|
80
|
+
|
|
81
|
+
That generates `journeys/customer-onboarding/` with a typed `defineJourney` definition, a `defineJourneyHandle` token, type-only imports for each named module, and (with `--persistence`) a `createWebStoragePersistence` adapter at `shell/src/customer-onboarding-persistence.ts`. It also installs `journeysPlugin()` on the shell's registry and adds `registry.registerJourney(...)`. The `start` step and per-module `transitions` map are left as `// TODO` comments - fill those in by working through the steps below.
|
|
82
|
+
|
|
83
|
+
If you're not using the CLI (or you want to understand the moving parts before reaching for it), the manual walkthrough follows.
|
|
84
|
+
|
|
85
|
+
## Quickstart
|
|
86
|
+
|
|
87
|
+
### 1. Declare a module's entry and exit vocabulary
|
|
88
|
+
|
|
89
|
+
Modules import only from `@modular-react/core`:
|
|
90
|
+
|
|
91
|
+
```ts
|
|
92
|
+
// modules/profile/src/exits.ts
|
|
93
|
+
// Each key here is an *exit name* the profile module can emit. The generic on
|
|
94
|
+
// `defineExit<T>()` declares the `output` payload shape that exit ships. The
|
|
95
|
+
// journey's transition map (see step 2) keys handlers off these exact names.
|
|
96
|
+
import { defineExit } from "@modular-react/core";
|
|
97
|
+
import type { PlanHint } from "./types.js";
|
|
98
|
+
|
|
99
|
+
export const profileExits = {
|
|
100
|
+
profileComplete: defineExit<{ customerId: string; hint: PlanHint }>(),
|
|
101
|
+
readyToBuy: defineExit<{ customerId: string; amount: number }>(),
|
|
102
|
+
needsMoreDetails: defineExit<{ customerId: string; missing: string }>(),
|
|
103
|
+
cancelled: defineExit(), // no output payload
|
|
104
|
+
} as const;
|
|
105
|
+
export type ProfileExits = typeof profileExits;
|
|
106
|
+
```
|
|
107
|
+
|
|
108
|
+
```tsx
|
|
109
|
+
// modules/profile/src/ReviewProfile.tsx
|
|
110
|
+
import type { ModuleEntryProps } from "@modular-react/core";
|
|
111
|
+
import type { ProfileExits } from "./exits.js";
|
|
112
|
+
|
|
113
|
+
export function ReviewProfile({
|
|
114
|
+
input,
|
|
115
|
+
exit,
|
|
116
|
+
}: ModuleEntryProps<{ customerId: string }, ProfileExits>) {
|
|
117
|
+
const customer = useCustomer(input.customerId);
|
|
118
|
+
const hint = suggestPlan(customer);
|
|
119
|
+
|
|
120
|
+
if (customer.readiness === "needs-details") {
|
|
121
|
+
return (
|
|
122
|
+
<button
|
|
123
|
+
onClick={() =>
|
|
124
|
+
exit("needsMoreDetails", {
|
|
125
|
+
customerId: input.customerId,
|
|
126
|
+
missing: customer.readinessDetail,
|
|
127
|
+
})
|
|
128
|
+
}
|
|
129
|
+
>
|
|
130
|
+
Flag for back-office
|
|
131
|
+
</button>
|
|
132
|
+
);
|
|
133
|
+
}
|
|
134
|
+
return (
|
|
135
|
+
<>
|
|
136
|
+
<ProfileSummary customer={customer} hint={hint} />
|
|
137
|
+
<button onClick={() => exit("profileComplete", { customerId: input.customerId, hint })}>
|
|
138
|
+
Pick a plan
|
|
139
|
+
</button>
|
|
140
|
+
{customer.readiness === "self-serve" && (
|
|
141
|
+
<button
|
|
142
|
+
onClick={() =>
|
|
143
|
+
exit("readyToBuy", {
|
|
144
|
+
customerId: input.customerId,
|
|
145
|
+
amount: selfServeAmount(customer),
|
|
146
|
+
})
|
|
147
|
+
}
|
|
148
|
+
>
|
|
149
|
+
Skip ahead - charge now
|
|
150
|
+
</button>
|
|
151
|
+
)}
|
|
152
|
+
<button onClick={() => exit("cancelled")}>Cancel</button>
|
|
153
|
+
</>
|
|
154
|
+
);
|
|
155
|
+
}
|
|
156
|
+
```
|
|
157
|
+
|
|
158
|
+
```ts
|
|
159
|
+
// modules/profile/src/index.ts
|
|
160
|
+
import { defineModule, defineEntry, schema } from "@modular-react/core";
|
|
161
|
+
import { profileExits } from "./exits.js";
|
|
162
|
+
import { ReviewProfile } from "./ReviewProfile.js";
|
|
163
|
+
|
|
164
|
+
export default defineModule({
|
|
165
|
+
id: "profile", // module id - referenced by journeys as `module: "profile"`
|
|
166
|
+
version: "1.0.0",
|
|
167
|
+
exitPoints: profileExits, // the full exit vocabulary shared by every entry on this module
|
|
168
|
+
entryPoints: {
|
|
169
|
+
// Each key here is an *entry name* - a typed way to open this module.
|
|
170
|
+
// Journeys reference it as `entry: "review"`.
|
|
171
|
+
review: defineEntry({
|
|
172
|
+
component: ReviewProfile,
|
|
173
|
+
input: schema<{ customerId: string }>(), // `input` shape passed when the entry is opened
|
|
174
|
+
}),
|
|
175
|
+
},
|
|
176
|
+
});
|
|
177
|
+
```
|
|
178
|
+
|
|
179
|
+
The `exits` const pattern (define once, share between component typing and module descriptor) is the canonical shape. `schema<T>()` is a **type-only** brand - zero runtime work.
|
|
180
|
+
|
|
181
|
+
### 2. Declare the journey
|
|
182
|
+
|
|
183
|
+
```ts
|
|
184
|
+
// journeys/customer-onboarding/src/journey.ts
|
|
185
|
+
import { defineJourney } from "@modular-react/journeys";
|
|
186
|
+
import type profileModule from "@myorg/module-profile";
|
|
187
|
+
import type planModule from "@myorg/module-plan";
|
|
188
|
+
import type billingModule from "@myorg/module-billing";
|
|
189
|
+
|
|
190
|
+
type Modules = {
|
|
191
|
+
readonly profile: typeof profileModule;
|
|
192
|
+
readonly plan: typeof planModule;
|
|
193
|
+
readonly billing: typeof billingModule;
|
|
194
|
+
};
|
|
195
|
+
|
|
196
|
+
interface OnboardingState {
|
|
197
|
+
customerId: string;
|
|
198
|
+
hint: PlanHint | null;
|
|
199
|
+
selectedPlan: SubscriptionPlan | null;
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
export const customerOnboardingJourney = defineJourney<Modules, OnboardingState>()({
|
|
203
|
+
id: "customer-onboarding",
|
|
204
|
+
version: "1.0.0",
|
|
205
|
+
initialState: ({ customerId }: { customerId: string }) => ({
|
|
206
|
+
customerId,
|
|
207
|
+
hint: null,
|
|
208
|
+
selectedPlan: null,
|
|
209
|
+
}),
|
|
210
|
+
start: (s) => ({ module: "profile", entry: "review", input: { customerId: s.customerId } }),
|
|
211
|
+
// The `transitions` map is nested three levels deep:
|
|
212
|
+
// 1. module id - which composed module (matches a key in `Modules` above)
|
|
213
|
+
// 2. entry name - which entry on that module the handler covers
|
|
214
|
+
// 3. exit name - which exit fired by that entry triggers the handler
|
|
215
|
+
// Each leaf is a pure function returning the next step, a state rewrite,
|
|
216
|
+
// a `complete`, or an `abort`.
|
|
217
|
+
transitions: {
|
|
218
|
+
profile: {
|
|
219
|
+
// module id - matches the `profile` key in `Modules` above
|
|
220
|
+
review: {
|
|
221
|
+
// entry name on the `profile` module - see `entryPoints.review` in modules/profile/src/index.ts
|
|
222
|
+
// Exit names below are the keys of `profileExits` declared in modules/profile/src/exits.ts.
|
|
223
|
+
profileComplete: ({ output, state }) => ({
|
|
224
|
+
state: { ...state, hint: output.hint },
|
|
225
|
+
next: {
|
|
226
|
+
module: "plan",
|
|
227
|
+
entry: "choose",
|
|
228
|
+
input: { customerId: state.customerId, hint: output.hint },
|
|
229
|
+
},
|
|
230
|
+
}),
|
|
231
|
+
readyToBuy: ({ output }) => ({
|
|
232
|
+
next: {
|
|
233
|
+
module: "billing",
|
|
234
|
+
entry: "collect",
|
|
235
|
+
input: { customerId: output.customerId, amount: output.amount },
|
|
236
|
+
},
|
|
237
|
+
}),
|
|
238
|
+
needsMoreDetails: ({ output }) => ({
|
|
239
|
+
abort: { reason: "profile-incomplete", missing: output.missing },
|
|
240
|
+
}),
|
|
241
|
+
cancelled: () => ({ abort: { reason: "rep-cancelled" } }),
|
|
242
|
+
},
|
|
243
|
+
},
|
|
244
|
+
// …transitions for `plan` and `billing` follow the same module -> entry -> exit shape.
|
|
245
|
+
},
|
|
246
|
+
});
|
|
247
|
+
```
|
|
248
|
+
|
|
249
|
+
Module imports are `import type` - the journey never pulls a module into its bundle. Runtime resolution happens by id against the registry.
|
|
250
|
+
|
|
251
|
+
### 3. Register the journey in the shell
|
|
252
|
+
|
|
253
|
+
Attach the journeys plugin to enable `registry.registerJourney`. Without `.use(journeysPlugin())` the method isn't on the base registry:
|
|
254
|
+
|
|
255
|
+
```ts
|
|
256
|
+
import { createRegistry } from "@react-router-modules/runtime"; // or @tanstack-react-modules/runtime
|
|
257
|
+
import { journeysPlugin } from "@modular-react/journeys";
|
|
258
|
+
import { customerOnboardingJourney } from "@myorg/journey-customer-onboarding";
|
|
259
|
+
|
|
260
|
+
const registry = createRegistry<AppDeps, AppSlots>({ stores, services }).use(
|
|
261
|
+
// Call once per registry - the plugin closes over its own registration
|
|
262
|
+
// list. The optional `onModuleExit` is the shell-wide dispatcher for
|
|
263
|
+
// module exits fired outside a journey step (see "`JourneyProvider` +
|
|
264
|
+
// context" below).
|
|
265
|
+
journeysPlugin({
|
|
266
|
+
onModuleExit: (ev) => workspace.closeTab(ev.tabId),
|
|
267
|
+
}),
|
|
268
|
+
);
|
|
269
|
+
|
|
270
|
+
registry.register(profileModule);
|
|
271
|
+
registry.register(planModule);
|
|
272
|
+
registry.register(billingModule);
|
|
273
|
+
|
|
274
|
+
// All registration options shown below are optional - a bare
|
|
275
|
+
// `registry.registerJourney(customerOnboardingJourney)` is valid and
|
|
276
|
+
// gives you an in-memory journey with no reload recovery.
|
|
277
|
+
registry.registerJourney(customerOnboardingJourney, {
|
|
278
|
+
persistence: defineJourneyPersistence<OnboardingInput, OnboardingState>({
|
|
279
|
+
keyFor: ({ input }) => `journey:${input.customerId}:customer-onboarding`,
|
|
280
|
+
load: (k) => backend.loadJourney(k),
|
|
281
|
+
save: (k, b) => backend.saveJourney(k, b),
|
|
282
|
+
remove: (k) => backend.deleteJourney(k),
|
|
283
|
+
}),
|
|
284
|
+
// Cap `history` growth for long-running journeys. See the caveat in
|
|
285
|
+
// [Bounded history (`maxHistory`)](#pattern--bounded-history-maxhistory).
|
|
286
|
+
// maxHistory: 50,
|
|
287
|
+
});
|
|
288
|
+
|
|
289
|
+
export const manifest = registry.resolveManifest();
|
|
290
|
+
```
|
|
291
|
+
|
|
292
|
+
`registry.registerJourney` validates the definition's **structural shape** right away (missing `id` / `version` / `transitions` etc. throw a `JourneyValidationError`). The deeper **contract check** - that every module id, entry name, exit name, and `allowBack` pairing actually matches the registered modules - runs at `resolveManifest()` / `resolve()` time.
|
|
293
|
+
|
|
294
|
+
`defineJourneyPersistence<TInput, TState>` is the recommended shape for the adapter: it ties `keyFor`'s `input` to the journey's `TInput` so no `as { customerId: string }` cast is needed, and typechecks `load` / `save` against the journey's state end-to-end. Plain objects matching `JourneyPersistence` still work if you prefer.
|
|
295
|
+
|
|
296
|
+
### 4. Render the journey in a tab (or any container)
|
|
297
|
+
|
|
298
|
+
The plugin mounts `<JourneyProvider>` automatically - descendant `<JourneyOutlet>` / `<ModuleTab>` nodes read the runtime (and the plugin-level `onModuleExit`) from context with no extra wiring. Just render the outlet wherever the step should live:
|
|
299
|
+
|
|
300
|
+
```tsx
|
|
301
|
+
import { JourneyOutlet, ModuleTab } from "@modular-react/journeys";
|
|
302
|
+
|
|
303
|
+
function TabContent({ tab, manifest }: { tab: Tab; manifest: ResolvedManifest }) {
|
|
304
|
+
if (tab.kind === "module") {
|
|
305
|
+
return (
|
|
306
|
+
<ModuleTab
|
|
307
|
+
module={manifest.moduleDescriptors[tab.moduleId]}
|
|
308
|
+
entry={tab.entry}
|
|
309
|
+
input={tab.input}
|
|
310
|
+
tabId={tab.tabId}
|
|
311
|
+
// The plugin's `onModuleExit` fires automatically for every module
|
|
312
|
+
// tab; pass `onExit` only for a per-tab override (typically "close
|
|
313
|
+
// this tab").
|
|
314
|
+
onExit={(ev) => workspace.closeTab(tab.tabId)}
|
|
315
|
+
/>
|
|
316
|
+
);
|
|
317
|
+
}
|
|
318
|
+
return (
|
|
319
|
+
<JourneyOutlet
|
|
320
|
+
instanceId={tab.instanceId}
|
|
321
|
+
loadingFallback={<LoadingSpinner />}
|
|
322
|
+
onFinished={(outcome) => workspace.closeTab(tab.tabId)}
|
|
323
|
+
/>
|
|
324
|
+
);
|
|
325
|
+
}
|
|
326
|
+
```
|
|
327
|
+
|
|
328
|
+
If the shell needs to reach a different runtime from the same tree (multi-tenant dashboards, split-screen agents), mount an explicit `<JourneyProvider runtime={otherRuntime}>` locally - the explicit prop wins over the plugin's provider. The manual-mount path is also still how you'd wire journeys in a shell that doesn't use `@react-router-modules/runtime` / `@tanstack-react-modules/runtime` at all.
|
|
329
|
+
|
|
330
|
+
`manifest.journeys` is always a runtime - even when no journey is registered it's a no-op runtime whose `listDefinitions()` / `listInstances()` return empty and whose `start()` throws the usual "unknown journey id" error. Shells don't need to null-guard it.
|
|
331
|
+
|
|
332
|
+
### 5. Open the journey
|
|
333
|
+
|
|
334
|
+
Export a **handle** alongside the journey definition so callers can open it with a typed `input` without importing the journey's runtime code:
|
|
335
|
+
|
|
336
|
+
```ts
|
|
337
|
+
// journeys/customer-onboarding/src/index.ts
|
|
338
|
+
import { defineJourneyHandle } from "@modular-react/journeys";
|
|
339
|
+
export const customerOnboardingHandle = defineJourneyHandle(customerOnboardingJourney);
|
|
340
|
+
```
|
|
341
|
+
|
|
342
|
+
The shell (or any module) then passes the handle to `runtime.start`. Typically this lives inside an `openTab`-style service so the workspace bookkeeping and the journey start are one call-site:
|
|
343
|
+
|
|
344
|
+
```ts
|
|
345
|
+
// In the shell, with `manifest.journeys` in scope:
|
|
346
|
+
const instanceId = manifest.journeys.start(customerOnboardingHandle, { customerId });
|
|
347
|
+
workspace.addJourneyTab({
|
|
348
|
+
instanceId,
|
|
349
|
+
journeyId: customerOnboardingHandle.id,
|
|
350
|
+
input: { customerId },
|
|
351
|
+
title: `Onboarding - ${customerName}`,
|
|
352
|
+
});
|
|
353
|
+
```
|
|
354
|
+
|
|
355
|
+
See the [customer-onboarding-journey example](../../examples/react-router/customer-onboarding-journey/) for a complete working shell, including the dispatcher that also handles the string-id form used by plugin-contributed navbar actions.
|
|
356
|
+
|
|
357
|
+
## Core concepts
|
|
358
|
+
|
|
359
|
+
### Entry points and exit points on a module
|
|
360
|
+
|
|
361
|
+
Two additive (optional) fields on `ModuleDescriptor`:
|
|
362
|
+
|
|
363
|
+
| Field | Shape | Purpose |
|
|
364
|
+
| ------------- | -------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
|
365
|
+
| `entryPoints` | `{ [name]: { component, input?, allowBack? } }`<br>or `{ [name]: { lazy: () => import("./X"), fallback?, input?, allowBack? } }` | Typed ways to open the module. A module can expose several. Each entry is either eager (a directly-bound component) or lazy (a dynamic-import factory — see [Pattern - lazy entry-points](#pattern---lazy-entry-points-code-splitting-per-step)). |
|
|
366
|
+
| `exitPoints` | `{ [name]: { output? } }` | The module's full outcome vocabulary. |
|
|
367
|
+
|
|
368
|
+
`ModuleEntryProps<TInput, TExits>` typed props for the component - `{ input, exit, goBack? }`, with `exit(name, output)` cross-checked against `TExits` at compile time.
|
|
369
|
+
|
|
370
|
+
Exits are **module-level, not per-entry** - every entry on a module shares the same `exitPoints` vocabulary. The journey's transition map (not the module) decides which exits a given entry actually uses, so two entries on the same module can map the same exit name to entirely different next steps.
|
|
371
|
+
|
|
372
|
+
### `allowBack` - three values
|
|
373
|
+
|
|
374
|
+
Declared per entry on the module, opted-in per transition on the journey. Both must agree for `goBack` to appear.
|
|
375
|
+
|
|
376
|
+
| Value | What happens on goBack |
|
|
377
|
+
| ------------------ | ------------------------------------------------------------------------------------------------------------------------------------- |
|
|
378
|
+
| `'preserve-state'` | History pops; journey state is untouched. |
|
|
379
|
+
| `'rollback'` | History pops AND journey state reverts to the snapshot taken before this step was entered (shallow clone - treat state as immutable). |
|
|
380
|
+
| `false` / absent | `goBack` is `undefined` in the component's props. Don't render the back button. |
|
|
381
|
+
|
|
382
|
+
The journey's transition map matches with `allowBack: true` on the exit block:
|
|
383
|
+
|
|
384
|
+
```ts
|
|
385
|
+
transitions: {
|
|
386
|
+
plan: { // module id
|
|
387
|
+
choose: { // entry name on the `plan` module
|
|
388
|
+
allowBack: true, // journey-side opt-in (paired with the entry's `allowBack` declaration)
|
|
389
|
+
choseStandard: …, // exit name -> handler (omitted)
|
|
390
|
+
},
|
|
391
|
+
},
|
|
392
|
+
}
|
|
393
|
+
```
|
|
394
|
+
|
|
395
|
+
A `resolveManifest()` error surfaces if the two sides disagree.
|
|
396
|
+
|
|
397
|
+
### Transition handlers are pure and synchronous
|
|
398
|
+
|
|
399
|
+
- No `await`.
|
|
400
|
+
- No React hooks.
|
|
401
|
+
- No store/service access.
|
|
402
|
+
- No side effects.
|
|
403
|
+
|
|
404
|
+
If a transition needs to fetch data between steps, put the fetch inside a dedicated loading entry point on a module - the module fetches in `useEffect` and exits with the loaded data. Side effects live in the observation hooks (`onTransition`, `onAbandon`, `onComplete`, `onAbort`), which are free to be noisy.
|
|
405
|
+
|
|
406
|
+
### Journey lifecycle
|
|
407
|
+
|
|
408
|
+
```text
|
|
409
|
+
user triggers exit('X', output)
|
|
410
|
+
→ runtime checks step token matches (stale callbacks are dropped)
|
|
411
|
+
→ runs transition handler (pure)
|
|
412
|
+
→ commits state + step + history atomically
|
|
413
|
+
→ fires onTransition (definition first, then registration option)
|
|
414
|
+
→ if terminal: fires onComplete / onAbort
|
|
415
|
+
→ schedules persistence.save (serialized per instance)
|
|
416
|
+
→ JourneyOutlet re-renders with new step or terminal state
|
|
417
|
+
```
|
|
418
|
+
|
|
419
|
+
A step-token counter guards against double-click and stale callbacks: any `exit()` / `goBack()` captured at mount time is dropped silently if the current step has moved on.
|
|
420
|
+
|
|
421
|
+
### Instance statuses
|
|
422
|
+
|
|
423
|
+
`JourneyInstance.status` runs through four values:
|
|
424
|
+
|
|
425
|
+
| Status | When | `step` | `<JourneyOutlet>` renders |
|
|
426
|
+
| ------------- | ----------------------------------------------------------------------------------------------- | ------------------------ | ---------------------------------- |
|
|
427
|
+
| `'loading'` | Async `persistence.load()` is in flight (first paint after `start()`). | `null` | `loadingFallback` |
|
|
428
|
+
| `'active'` | The normal running state - `step` points at the module/entry currently on screen. | `{ moduleId, entry, … }` | The step component |
|
|
429
|
+
| `'completed'` | Terminal. A transition returned `{ complete }`. | `null` | `null` (after firing `onFinished`) |
|
|
430
|
+
| `'aborted'` | Terminal. A transition returned `{ abort }`, the outlet unmounted, or `runtime.end` was called. | `null` | `null` (after firing `onFinished`) |
|
|
431
|
+
|
|
432
|
+
Terminal instances stay in memory (so late subscribers can read `terminalPayload`) until you call `runtime.forget(id)` / `runtime.forgetTerminal()`.
|
|
433
|
+
|
|
434
|
+
### Keys, idempotency, and "resume vs new"
|
|
435
|
+
|
|
436
|
+
When persistence is configured, `runtime.start(journeyId, input)` is **idempotent per persistence key**: two calls with inputs that resolve to the same `keyFor` return the same `instanceId`. This is the mechanism that turns "open the Alice onboarding tab" into "resume Alice's onboarding tab" on reload - no explicit `resume()` API is needed. See [Persistence](#persistence) for the probe rules.
|
|
437
|
+
|
|
438
|
+
Without persistence, every `start()` mints a fresh instance. Two calls = two independent journeys that happen to share a journey id.
|
|
439
|
+
|
|
440
|
+
## Authoring patterns
|
|
441
|
+
|
|
442
|
+
Patterns below are small, composable recipes - most real apps use two or three of them together.
|
|
443
|
+
|
|
444
|
+
### Pattern - an exits const shared between the component and the descriptor
|
|
445
|
+
|
|
446
|
+
The canonical module shape: define exits once, consume them from the component (for a typed `exit` prop) and from the descriptor (for validation). No duplication.
|
|
447
|
+
|
|
448
|
+
```ts
|
|
449
|
+
// modules/profile/src/exits.ts
|
|
450
|
+
export const profileExits = {
|
|
451
|
+
profileComplete: defineExit<{ customerId: string; hint: PlanHint }>(),
|
|
452
|
+
cancelled: defineExit(),
|
|
453
|
+
} as const;
|
|
454
|
+
export type ProfileExits = typeof profileExits;
|
|
455
|
+
```
|
|
456
|
+
|
|
457
|
+
```tsx
|
|
458
|
+
// modules/profile/src/ReviewProfile.tsx
|
|
459
|
+
export function ReviewProfile({
|
|
460
|
+
input,
|
|
461
|
+
exit,
|
|
462
|
+
}: ModuleEntryProps<{ customerId: string }, ProfileExits>) {
|
|
463
|
+
/* exit('profileComplete', { customerId: input.customerId, hint }) is type-checked */
|
|
464
|
+
}
|
|
465
|
+
```
|
|
466
|
+
|
|
467
|
+
```ts
|
|
468
|
+
// modules/profile/src/index.ts
|
|
469
|
+
export default defineModule({
|
|
470
|
+
id: "profile",
|
|
471
|
+
version: "1.0.0",
|
|
472
|
+
exitPoints: profileExits,
|
|
473
|
+
entryPoints: {
|
|
474
|
+
review: defineEntry({ component: ReviewProfile, input: schema<{ customerId: string }>() }),
|
|
475
|
+
},
|
|
476
|
+
});
|
|
477
|
+
```
|
|
478
|
+
|
|
479
|
+
Note: `defineModule` is called **without** shell-level generics in this example. That keeps the descriptor's literal type (including the narrow `entryPoints` / `exitPoints` keys) preserved so the journey definition can cross-check transitions against `typeof moduleDescriptor`. A typed shell can still enforce `AppDependencies` / `AppSlots` via `defineModule<AppDeps, AppSlots>()` at the call site if desired - the tradeoff is that the narrow entry/exit types must be recovered via `typeof` in the journey's module map either way.
|
|
480
|
+
|
|
481
|
+
### Pattern - a module exposing several entries
|
|
482
|
+
|
|
483
|
+
```ts
|
|
484
|
+
export default defineModule({
|
|
485
|
+
id: "billing",
|
|
486
|
+
version: "1.0.0",
|
|
487
|
+
exitPoints: billingExits,
|
|
488
|
+
entryPoints: {
|
|
489
|
+
collect: defineEntry({ component: CollectPayment, input: schema<CollectInput>() }),
|
|
490
|
+
startTrial: defineEntry({ component: StartTrial, input: schema<TrialInput>() }),
|
|
491
|
+
},
|
|
492
|
+
});
|
|
493
|
+
```
|
|
494
|
+
|
|
495
|
+
The journey's transition map targets `{ module: 'billing', entry: 'collect' }` or `'startTrial'` - the discriminated `StepSpec` enforces that `input` matches the chosen entry.
|
|
496
|
+
|
|
497
|
+
### Pattern - lazy entry-points (code-splitting per step)
|
|
498
|
+
|
|
499
|
+
For heavy steps (rich editors, charting libraries, large vendor bundles) declare `lazy: () => import('./HeavyStep')` instead of `component:`. The runtime wraps the resolved component in `React.lazy` + `<Suspense>` for you and exposes an idempotent `preload()` that the outlet calls during idle time (see [auto-preload](#pattern---declared-targets-with-definetransition-auto-preload--narrowed-return-type)). This eliminates the per-entry `LazyXxxStep.tsx` wrapper consumers used to write to get past the descriptor's "must be a function" validation.
|
|
500
|
+
|
|
501
|
+
```tsx
|
|
502
|
+
// modules/billing/src/index.ts
|
|
503
|
+
import { defineEntry, defineModule, schema } from "@modular-react/core";
|
|
504
|
+
import { billingExits } from "./exits.js";
|
|
505
|
+
|
|
506
|
+
export default defineModule({
|
|
507
|
+
id: "billing",
|
|
508
|
+
version: "1.0.0",
|
|
509
|
+
exitPoints: billingExits,
|
|
510
|
+
entryPoints: {
|
|
511
|
+
collect: defineEntry({
|
|
512
|
+
lazy: () => import("./CollectPayment.js"),
|
|
513
|
+
fallback: <PaymentSkeleton />, // optional <Suspense fallback>
|
|
514
|
+
input: schema<{ customerId: string; amount: number }>(),
|
|
515
|
+
}),
|
|
516
|
+
},
|
|
517
|
+
});
|
|
518
|
+
```
|
|
519
|
+
|
|
520
|
+
Rules:
|
|
521
|
+
|
|
522
|
+
- **Eager and lazy are mutually exclusive at the type level.** Declaring both `component` and `lazy` on the same entry is a TypeScript error (and a `validateModuleEntryExit` issue — defense in depth). Declaring neither is also flagged.
|
|
523
|
+
- **`fallback` only on lazy entries.** Eager entries don't suspend, so the field is typed `never` on `EagerModuleEntryPoint`. Trying to pass it would be confusing — make the trap visible at the type level.
|
|
524
|
+
- **Importer signature matches `React.lazy`.** Standard `() => import("./X")` works (default export). The runtime also normalizes a module that exports the component directly, so `() => Promise.resolve(MyComponent)` works in tests.
|
|
525
|
+
- **The lazy import is memoized per entry-object identity** via a process-local `WeakMap` in `@modular-react/react`. A descriptor is fetched at most once across all renders, hot reloads producing fresh descriptor objects get fresh wrappers, and StrictMode's double-mount is safe.
|
|
526
|
+
- **Manual prefetch** is exposed via `preloadEntry(entry)` from `@modular-react/react` — useful for hover-prefetch UIs (`onMouseEnter={() => preloadEntry(entry)}`), navigation gestures, or warming a chunk from a `useEffect` that knows the user is about to advance.
|
|
527
|
+
- **Errors from the import** propagate through Suspense and are caught by the outlet's existing `StepErrorBoundary`, going through `onStepError` (`abort | retry | ignore`) just like a component throw. A permanently-failing import re-throws the cached rejection on retry; the existing `retryLimit` budget applies.
|
|
528
|
+
- **SSR**: `React.lazy` resolution is server-renderable in React 19+; the auto-preload effect is browser-only (no `useEffect` on the server).
|
|
529
|
+
|
|
530
|
+
### Pattern - a loading entry point for async work
|
|
531
|
+
|
|
532
|
+
Transitions are pure and synchronous. When a step needs to fetch data between user actions, put the fetch inside a **loading entry** on the next module; that module fires an exit with the loaded data, and the journey transitions from that exit as usual.
|
|
533
|
+
|
|
534
|
+
```tsx
|
|
535
|
+
// modules/risk/src/LoadRiskReport.tsx
|
|
536
|
+
export function LoadRiskReport({
|
|
537
|
+
input,
|
|
538
|
+
exit,
|
|
539
|
+
}: ModuleEntryProps<{ customerId: string }, RiskExits>) {
|
|
540
|
+
useEffect(() => {
|
|
541
|
+
let cancelled = false;
|
|
542
|
+
(async () => {
|
|
543
|
+
try {
|
|
544
|
+
const report = await api.fetchRiskReport(input.customerId);
|
|
545
|
+
if (!cancelled) exit("reportReady", { report });
|
|
546
|
+
} catch (err) {
|
|
547
|
+
if (!cancelled) exit("failed", { reason: String(err) });
|
|
548
|
+
}
|
|
549
|
+
})();
|
|
550
|
+
return () => {
|
|
551
|
+
cancelled = true;
|
|
552
|
+
};
|
|
553
|
+
}, [input.customerId]);
|
|
554
|
+
|
|
555
|
+
return <LoadingSpinner label="Computing risk…" />;
|
|
556
|
+
}
|
|
557
|
+
```
|
|
558
|
+
|
|
559
|
+
```ts
|
|
560
|
+
// journey - same module -> entry -> exit nesting as the Quickstart example.
|
|
561
|
+
transitions: {
|
|
562
|
+
account: { // `account` module id
|
|
563
|
+
review: { // `review` entry on the account module
|
|
564
|
+
needsRiskCheck: ({ output }) => ({ // exit fired by ReviewAccount when a risk check is needed
|
|
565
|
+
next: { module: "risk", entry: "load", input: { customerId: output.customerId } },
|
|
566
|
+
}),
|
|
567
|
+
},
|
|
568
|
+
},
|
|
569
|
+
risk: { // `risk` module id
|
|
570
|
+
load: { // `load` entry - the LoadRiskReport component shown above
|
|
571
|
+
reportReady: ({ output, state }) => ({ // exit fired when the async fetch resolves
|
|
572
|
+
state: { ...state, risk: output.report },
|
|
573
|
+
next: { module: "decisions", entry: "choose", input: { risk: output.report } },
|
|
574
|
+
}),
|
|
575
|
+
failed: ({ output }) => ({ abort: { reason: "risk-check-failed", detail: output.reason } }),
|
|
576
|
+
},
|
|
577
|
+
},
|
|
578
|
+
}
|
|
579
|
+
```
|
|
580
|
+
|
|
581
|
+
The cancellation flag matters: if the user clicks `goBack` before the fetch resolves, the component unmounts and the step token advances. A stale `exit('reportReady', …)` would be dropped by the runtime anyway (see [step tokens](#errors-races-and-edge-cases)), but explicit cancellation avoids the race and spurious network work.
|
|
582
|
+
|
|
583
|
+
### Pattern - optional exits (entries that don't emit every exit)
|
|
584
|
+
|
|
585
|
+
A module's `exitPoints` declares its **full** vocabulary. Individual entries don't have to emit every exit, and individual journeys don't have to handle every exit. If an entry fires an exit that has no handler in the current journey, the call is ignored and a dev-mode warning is logged - useful during refactors but usually a bug. Keep the exit vocabulary tight and prune unused exits.
|
|
586
|
+
|
|
587
|
+
### Pattern - `allowBack` on an entry, `allowBack: true` on the transition
|
|
588
|
+
|
|
589
|
+
For `goBack` to appear in the component's props, **both sides** must opt in:
|
|
590
|
+
|
|
591
|
+
```ts
|
|
592
|
+
// module - declares the entry's back behaviour
|
|
593
|
+
entryPoints: {
|
|
594
|
+
choose: defineEntry({ component: ChoosePlan, input: schema<ChooseInput>(), allowBack: "preserve-state" }),
|
|
595
|
+
}
|
|
596
|
+
|
|
597
|
+
// journey - opts that entry into back navigation
|
|
598
|
+
transitions: {
|
|
599
|
+
plan: { // module id
|
|
600
|
+
choose: { // entry name on `plan` (matches the entry above)
|
|
601
|
+
allowBack: true, // journey-side opt-in
|
|
602
|
+
// …exit handlers keyed by exit name…
|
|
603
|
+
},
|
|
604
|
+
},
|
|
605
|
+
}
|
|
606
|
+
```
|
|
607
|
+
|
|
608
|
+
Mismatched declarations are caught at `resolveManifest()` / `resolve()` time via `validateJourneyContracts` - the journey's `allowBack: true` with an entry that declared `allowBack: false` (or omitted it) is an aggregated validation error, not a runtime surprise.
|
|
609
|
+
|
|
610
|
+
## Journey definition patterns
|
|
611
|
+
|
|
612
|
+
### Pattern - branching on exit name
|
|
613
|
+
|
|
614
|
+
Most journeys branch by picking a different `next` step per exit name. `StepSpec`'s discriminated union means `input` on each branch is type-checked against the target entry:
|
|
615
|
+
|
|
616
|
+
```ts
|
|
617
|
+
profile: { // module id
|
|
618
|
+
review: { // entry name on `profile`
|
|
619
|
+
profileComplete: ({ output, state }) => ({ // exit name -> branch into the `plan` module
|
|
620
|
+
state: { ...state, hint: output.hint },
|
|
621
|
+
next: { module: "plan", entry: "choose", input: { customerId: state.customerId, hint: output.hint } },
|
|
622
|
+
}),
|
|
623
|
+
readyToBuy: ({ output }) => ({ // different exit -> branch into `billing` instead
|
|
624
|
+
next: { module: "billing", entry: "collect", input: { customerId: output.customerId, amount: output.amount } },
|
|
625
|
+
}),
|
|
626
|
+
},
|
|
627
|
+
},
|
|
628
|
+
```
|
|
629
|
+
|
|
630
|
+
### Pattern - declared targets with `defineTransition` (auto-preload + narrowed return type)
|
|
631
|
+
|
|
632
|
+
Wrap a handler with `defineTransition` to declare every outcome it may take — both next-step destinations and terminal arms. Two effects from one declaration:
|
|
633
|
+
|
|
634
|
+
1. **Runtime — preload precision.** `<JourneyOutlet>`'s default `preload="precise"` mode reads `targets` and warms exactly the declared next-step chunks during idle time, so navigating Next finds the chunk already cached.
|
|
635
|
+
2. **Type-level — the handler's return is constrained to the declared arms.** Returning an arm that wasn't declared (e.g. `abort` when only `next` was declared) is a compile error.
|
|
636
|
+
|
|
637
|
+
`targets` accepts a mixed array of:
|
|
638
|
+
|
|
639
|
+
- `{ module, entry }` — same shape as `next:` minus the runtime-computed `input`. One per next-step candidate.
|
|
640
|
+
- `"complete"` / `"abort"` / `"invoke"` — string sentinels for the terminal arms. Declaring `"complete"` permits `{ complete: ... }` returns; `"abort"` permits `{ abort: ... }`; `"invoke"` permits `{ invoke: ... }` (the journey's `invokes:` field remains the closed-set declaration the runtime cycle-guards check against — the sentinel is just "this handler may invoke something").
|
|
641
|
+
|
|
642
|
+
The helper has two call shapes:
|
|
643
|
+
|
|
644
|
+
- **Curried (recommended)** — `defineTransition<TModules, TState>()` binds the journey's generics once. Handlers wrapped with the returned binder get `targets` autocompleted to valid step refs + sentinels and the handler's return narrowed to the declared arms.
|
|
645
|
+
- **Bare** — `defineTransition({ targets, handle })` for one-off use. `targets` accepts any well-formed step-ref / sentinel without TModules-level checking; the handler's return is not contextually narrowed.
|
|
646
|
+
|
|
647
|
+
`targets` is **mandatory on every `defineTransition` call** — the wrapper's whole point is to enumerate the possible outcomes, and an empty/missing array would silently sit out of precise-mode preload while looking annotated. If you don't want to declare outcomes, use a bare function — the runtime invocation path is identical.
|
|
648
|
+
|
|
649
|
+
```ts
|
|
650
|
+
import { defineJourney, defineTransition } from "@modular-react/journeys";
|
|
651
|
+
|
|
652
|
+
// Bind the journey's generics once — every `transition({ ... })` call below
|
|
653
|
+
// gets autocomplete on `targets` and contextual narrowing on `next`. Naming
|
|
654
|
+
// mirrors `selectModule`: a descriptive verb for the binder, not an
|
|
655
|
+
// abbreviation (`tx` reads as "transaction" in most codebases).
|
|
656
|
+
const transition = defineTransition<OnboardingModules, OnboardingState>();
|
|
657
|
+
|
|
658
|
+
export const onboardingJourney = defineJourney<OnboardingModules, OnboardingState>()({
|
|
659
|
+
// ...
|
|
660
|
+
transitions: {
|
|
661
|
+
profile: {
|
|
662
|
+
review: {
|
|
663
|
+
// Multi-target next: outlet preloads BOTH chunks during the idle
|
|
664
|
+
// window after profile/review mounts. Handler return is narrowed
|
|
665
|
+
// to `{ next: planChoose | billingCollect }` — returning `abort`
|
|
666
|
+
// here would be a compile error.
|
|
667
|
+
profileComplete: transition({
|
|
668
|
+
targets: [
|
|
669
|
+
{ module: "plan", entry: "choose" },
|
|
670
|
+
{ module: "billing", entry: "collect" },
|
|
671
|
+
],
|
|
672
|
+
handle: ({ output, state }) => ({
|
|
673
|
+
state: { ...state, hint: output.hint },
|
|
674
|
+
next:
|
|
675
|
+
output.hint === "cheap"
|
|
676
|
+
? {
|
|
677
|
+
module: "plan",
|
|
678
|
+
entry: "choose",
|
|
679
|
+
input: { customerId: state.customerId, hint: output.hint },
|
|
680
|
+
}
|
|
681
|
+
: {
|
|
682
|
+
module: "billing",
|
|
683
|
+
entry: "collect",
|
|
684
|
+
input: { customerId: state.customerId, amount: 0 },
|
|
685
|
+
},
|
|
686
|
+
}),
|
|
687
|
+
}),
|
|
688
|
+
// Mixed: handler may advance OR abort. Both arms are declared.
|
|
689
|
+
review: transition({
|
|
690
|
+
targets: [{ module: "plan", entry: "choose" }, "abort"],
|
|
691
|
+
handle: ({ output }) =>
|
|
692
|
+
output.ok
|
|
693
|
+
? {
|
|
694
|
+
next: {
|
|
695
|
+
module: "plan",
|
|
696
|
+
entry: "choose",
|
|
697
|
+
input: { customerId: "c", hint: "cheap" },
|
|
698
|
+
},
|
|
699
|
+
}
|
|
700
|
+
: { abort: { reason: "rejected" } },
|
|
701
|
+
}),
|
|
702
|
+
// Terminal-only: declaring `"abort"` lets the catalog harvester
|
|
703
|
+
// surface the abort flag without an AST walk over the handler body,
|
|
704
|
+
// and constrains the return to just the abort arm at compile time.
|
|
705
|
+
cancelled: transition({
|
|
706
|
+
targets: ["abort"],
|
|
707
|
+
handle: () => ({ abort: { reason: "user-cancelled" } }),
|
|
708
|
+
}),
|
|
709
|
+
},
|
|
710
|
+
},
|
|
711
|
+
},
|
|
712
|
+
});
|
|
713
|
+
```
|
|
714
|
+
|
|
715
|
+
Why explicit declarations rather than inferring from the handler body? Handler bodies are dynamic (`next: cond ? A : B`) and may have side effects, so the runtime can't safely run them speculatively to enumerate destinations. One declarative line per wrapped transition is the trade-off — and it doubles as the catalog's authoritative outcome map (no AST walking for `aborts` / `completes` flags either).
|
|
716
|
+
|
|
717
|
+
**Bare-function handlers still work.** The runtime invocation path is identical, and they sit out of precise-mode preload (`preload="aggressive"` is the fallback). Migrate handlers that fan out to heavy steps first; everything else stays as-is.
|
|
718
|
+
|
|
719
|
+
### Pattern - branching on state/output inside a handler
|
|
720
|
+
|
|
721
|
+
Handlers are plain functions - branch with `if` / `switch` on output or state. Return whichever `TransitionResult` makes sense.
|
|
722
|
+
|
|
723
|
+
```ts
|
|
724
|
+
review: { // entry name (the surrounding module key is omitted for brevity)
|
|
725
|
+
done: ({ output, state }) => // exit name fired by the `review` entry
|
|
726
|
+
output.needsKyc
|
|
727
|
+
? { next: { module: "kyc", entry: "collect", input: { customerId: state.customerId } } }
|
|
728
|
+
: { complete: { reason: "ok" } },
|
|
729
|
+
}
|
|
730
|
+
```
|
|
731
|
+
|
|
732
|
+
### Pattern - exhaustive state-driven module dispatch (`selectModule`)
|
|
733
|
+
|
|
734
|
+
When a transition needs to dispatch to one of N modules based on a discriminator (a value picked earlier in the flow, a kind from the previous module's output, etc.), a hand-written `switch` works but loses two things: exhaustiveness when the discriminator's union grows, and per-branch input narrowing without per-branch ceremony. `selectModule<TModules>()` collapses both into one declarative call:
|
|
735
|
+
|
|
736
|
+
```ts
|
|
737
|
+
import { selectModule } from "@modular-react/journeys";
|
|
738
|
+
|
|
739
|
+
const select = selectModule<IntegrationModules>();
|
|
740
|
+
|
|
741
|
+
chooser: { // module id - the picker module
|
|
742
|
+
pick: { // entry name on `chooser` - renders the integration list
|
|
743
|
+
chosen: ({ output, state }) => ({ // exit fired when the user picks an integration
|
|
744
|
+
state: { ...state, selected: output.kind },
|
|
745
|
+
next: select(output.kind, {
|
|
746
|
+
// Each key below is a target module id; `entry` / `input` are checked against that module.
|
|
747
|
+
github: { entry: "configure", input: { workspaceId: state.workspaceId, repo: output.repo } },
|
|
748
|
+
strapi: { entry: "configure", input: { workspaceId: state.workspaceId, url: output.url } },
|
|
749
|
+
contentful: { entry: "configure", input: { workspaceId: state.workspaceId, spaceId: output.spaceId } },
|
|
750
|
+
}),
|
|
751
|
+
}),
|
|
752
|
+
},
|
|
753
|
+
},
|
|
754
|
+
```
|
|
755
|
+
|
|
756
|
+
The cases object is `Record<TKey, …>`, so adding a new value to the discriminator's union without a matching branch is a compile error. Each case's `entry` is type-narrowed against that module's `entryPoints`; `input` is checked against that entry - pasting a `strapi`-shaped input under the `github` key fails at the call site.
|
|
757
|
+
|
|
758
|
+
**Limit.** The discriminator key must equal the target module id. When they differ (e.g. `tier: "free" | "paid"` dispatching to module ids `trial-onboarding` / `billing-onboarding`), fall back to a `switch` returning `next` per branch - the helper's value is the exhaustiveness + per-branch typing, not the lookup itself.
|
|
759
|
+
|
|
760
|
+
### Pattern - fallback dispatch (`selectModuleOrDefault`)
|
|
761
|
+
|
|
762
|
+
When most discriminator values funnel into a generic module and only a few warrant their own dedicated step, use the sibling `selectModuleOrDefault` - it accepts a partial cases map plus an explicit fallback `StepSpec`:
|
|
763
|
+
|
|
764
|
+
```ts
|
|
765
|
+
import { selectModuleOrDefault } from "@modular-react/journeys";
|
|
766
|
+
|
|
767
|
+
const select = selectModuleOrDefault<IntegrationModules>();
|
|
768
|
+
|
|
769
|
+
chooser: { // module id - the picker module
|
|
770
|
+
pick: { // entry name on `chooser`
|
|
771
|
+
chosen: ({ output, state }) => ({ // exit fired with the chosen integration kind
|
|
772
|
+
state: { ...state, selected: output.kind },
|
|
773
|
+
next: select(
|
|
774
|
+
output.kind,
|
|
775
|
+
{
|
|
776
|
+
// Keys are target module ids. Only github + strapi earn dedicated configure steps.
|
|
777
|
+
github: { entry: "configure", input: { workspaceId: state.workspaceId, repo: "..." } },
|
|
778
|
+
strapi: { entry: "configure", input: { workspaceId: state.workspaceId } },
|
|
779
|
+
},
|
|
780
|
+
// contentful, notion, future kinds - all flow through the
|
|
781
|
+
// generic configure step.
|
|
782
|
+
{ module: "generic", entry: "configure", input: { workspaceId: state.workspaceId, kind: output.kind } },
|
|
783
|
+
),
|
|
784
|
+
}),
|
|
785
|
+
},
|
|
786
|
+
},
|
|
787
|
+
```
|
|
788
|
+
|
|
789
|
+
It's a separate function, not a third argument on `selectModule`, so the _exhaustive_ call site is visually distinct from the _fallback-allowed_ one - adding a third argument later can't silently disable the missing-branch compile error.
|
|
790
|
+
|
|
791
|
+
**When to prefer which.** Pick `selectModule` (exhaustive) if every discriminator value gets its own dedicated module - the missing-case error is the whole point. Pick `selectModuleOrDefault` (fallback) when you have a real catch-all module: most kinds funnel through generic shape, only a handful warrant tailored UI. A third-party plugin system that lets new integration kinds appear at runtime always wants the fallback form, since the journey can't know every kind ahead of time.
|
|
792
|
+
|
|
793
|
+
#### Pairing with slot-driven discovery
|
|
794
|
+
|
|
795
|
+
`selectModule` / `selectModuleOrDefault` plays well with the slots system for the common "chooser → specific" shape:
|
|
796
|
+
|
|
797
|
+
- Each module contributes itself to a shared slot (e.g. `slots: { integrations: [{ id: "github", label: "GitHub", … }] }`).
|
|
798
|
+
- The chooser module reads `useSlots<AppSlots>().integrations` and renders one row per contribution - staying agnostic of which integrations exist.
|
|
799
|
+
- The journey's `chosen` transition uses `selectModule(Or)` to dispatch on the picked id.
|
|
800
|
+
|
|
801
|
+
Slots drive presentation (dynamic, discoverable); the journey owns dispatch (typed, statically declared). See [`examples/react-router/integration-setup-journey/`](../../examples/react-router/integration-setup-journey/) for an end-to-end example with both forms exercised by Playwright.
|
|
802
|
+
|
|
803
|
+
### Pattern - terminal with structured payload
|
|
804
|
+
|
|
805
|
+
`complete` and `abort` both take `unknown` - pass any shape you want. Consumers read it via `instance.terminalPayload` or the `outcome.payload` arg to `onFinished`.
|
|
806
|
+
|
|
807
|
+
```ts
|
|
808
|
+
// `paid` here is an exit name (the surrounding `<module>: { <entry>: { … } }` keys are omitted).
|
|
809
|
+
paid: ({ output }) => ({ complete: { kind: "paid", reference: output.reference, amount: output.amount } }),
|
|
810
|
+
```
|
|
811
|
+
|
|
812
|
+
### Pattern - overriding `state` during a transition
|
|
813
|
+
|
|
814
|
+
Every handler is free to rewrite state:
|
|
815
|
+
|
|
816
|
+
```ts
|
|
817
|
+
// `choseStandard` is an exit name on some <module>.<entry> (keys omitted for brevity).
|
|
818
|
+
choseStandard: ({ output, state }) => ({
|
|
819
|
+
state: { ...state, selectedPlan: output.plan },
|
|
820
|
+
next: { module: "billing", entry: "collect", input: { customerId: state.customerId, amount: output.plan.monthly } },
|
|
821
|
+
}),
|
|
822
|
+
```
|
|
823
|
+
|
|
824
|
+
If you omit `state`, the incoming state is preserved. Writing `state: undefined` is treated as an explicit write (for state types that allow it) - the key `"state"` being _present_ is what signals intent.
|
|
825
|
+
|
|
826
|
+
### Pattern - keeping state immutable
|
|
827
|
+
|
|
828
|
+
Snapshots captured for `allowBack: 'rollback'` are **shallow clones**. Deep mutation of nested values corrupts the snapshot. Treat state as immutable - return a new object every time. In dev mode the runtime shallow-freezes the snapshot so a top-level mutation throws loudly.
|
|
829
|
+
|
|
830
|
+
### Pattern - bounded history (`maxHistory`)
|
|
831
|
+
|
|
832
|
+
Register with a cap to prevent unbounded growth in long-running journeys:
|
|
833
|
+
|
|
834
|
+
```ts
|
|
835
|
+
registry.registerJourney(journey, { maxHistory: 50 });
|
|
836
|
+
```
|
|
837
|
+
|
|
838
|
+
Caveat: a cap smaller than the deepest reachable back-chain silently breaks `goBack` past the trim point (the rollback snapshot `goBack` would restore is among the dropped entries). Size it to at least the longest user-reachable back chain, or treat it as a hard "no-one will navigate back this far" window.
|
|
839
|
+
|
|
840
|
+
Omitting `maxHistory`, or passing `0` or a negative number, leaves history unbounded.
|
|
841
|
+
|
|
842
|
+
### Pattern - module compatibility (`moduleCompat`)
|
|
843
|
+
|
|
844
|
+
A journey is implicitly coupled to the **exit names**, **input shapes**, and **`allowBack` semantics** of every module it references in `transitions`. When those modules ship from other teams, a backwards-incompatible bump on the other side ("we renamed `success` to `done`") would otherwise only surface at runtime — when the user actually navigated to the now-broken step.
|
|
845
|
+
|
|
846
|
+
`moduleCompat` is a registration-time guard: declare the npm-style version range your journey was authored against, and the journeys plugin checks it against each module's `version` field at `resolveManifest()` time. An incompatible deployment refuses to come up at all instead of silently breaking one user mid-flow.
|
|
847
|
+
|
|
848
|
+
```ts
|
|
849
|
+
defineJourney<OnboardingModules, OnboardingState>()({
|
|
850
|
+
id: "customer-onboarding",
|
|
851
|
+
version: "1.0.0",
|
|
852
|
+
moduleCompat: {
|
|
853
|
+
profile: "^1.0.0",
|
|
854
|
+
plan: ">=1.5.0 <2.0.0",
|
|
855
|
+
// multiple major lines accepted explicitly
|
|
856
|
+
billing: "^2.0.0 || ^3.0.0",
|
|
857
|
+
},
|
|
858
|
+
initialState: () => ({
|
|
859
|
+
/* ... */
|
|
860
|
+
}),
|
|
861
|
+
start: () => ({
|
|
862
|
+
module: "profile",
|
|
863
|
+
entry: "review",
|
|
864
|
+
input: {
|
|
865
|
+
/* ... */
|
|
866
|
+
},
|
|
867
|
+
}),
|
|
868
|
+
transitions: {
|
|
869
|
+
/* ... */
|
|
870
|
+
},
|
|
871
|
+
});
|
|
872
|
+
```
|
|
873
|
+
|
|
874
|
+
What the validator does at `resolveManifest()` time:
|
|
875
|
+
|
|
876
|
+
| Situation | Result |
|
|
877
|
+
| ---------------------------------------------- | -------------------------------------------------------------------- |
|
|
878
|
+
| Range admits the registered module's `version` | OK. |
|
|
879
|
+
| Range does not admit it | `JourneyValidationError` listing journey id, module, range, version. |
|
|
880
|
+
| Range names a module that isn't registered | `JourneyValidationError` ("not registered"). |
|
|
881
|
+
| Range string is malformed | `JourneyValidationError` echoing the offending input. |
|
|
882
|
+
| Module's own `version` is malformed | `JourneyValidationError` ("unparseable version"). |
|
|
883
|
+
|
|
884
|
+
All issues are accumulated and reported in one error so a deployment with several mismatched teams sees the full list in one CI run, not one bug at a time.
|
|
885
|
+
|
|
886
|
+
**Range syntax.** A subset of npm semver: caret (`^1.2.3`), tilde (`~1.2.3`), x-range (`1.x`, `1.2.x`, `*`), comparators (`>=1.2.3`, `<2.0.0`, `=1.2.3`), AND (whitespace-separated), OR (`||`-separated), and hyphen ranges (`1.2.3 - 2.0.0`). Pre-release tags and build metadata are not supported — module versions in this framework are stable releases by contract.
|
|
887
|
+
|
|
888
|
+
**Why a custom semver implementation?** The full `semver` package handles a much wider grammar than this use-case needs and parses regex-heavy on every call. The journeys-internal subset was originally cross-checked against `semver@7.7.4` and ran ~5× faster on the cached path and ~1.7× faster on one-shot parses on a representative grid; see [`bench/semver.bench.ts`](bench/semver.bench.ts) for the historic numbers and a self-contained benchmark of the local implementation. The cross-checked outcomes are frozen as a fixture grid in [`src/semver.test.ts`](src/semver.test.ts) so any regression in our implementation is caught without keeping `semver` as a devDependency. If a range or version is outside the supported subset the parser throws synchronously, so an untested edge case fails loudly at registration rather than as a silent "no match."
|
|
889
|
+
|
|
890
|
+
**When to omit it.** Modules that you and the journey ship together (same team, same release cadence, same monorepo) gain nothing from a compat declaration — the structural validators (`transitions` referencing real modules / entries / exits) already catch shape drift. The compat check earns its keep when the journey and a module are versioned independently.
|
|
891
|
+
|
|
892
|
+
**Coverage is opt-in, not exhaustive.** `moduleCompat` is a one-way declaration: any module you list there is checked against the registered version, and a stale entry naming a module that isn't registered fails loudly. But a module the journey actually transitions through and _doesn't_ list in `moduleCompat` is silently fine — the validator does not require coverage for every module referenced by `transitions`. List the modules whose compatibility you care about; omit the rest. Module ids are also typed against the journey's `TModules` map, so a typo on a known module id is a compile error.
|
|
893
|
+
|
|
894
|
+
## Composing journeys (invoke / resume)
|
|
895
|
+
|
|
896
|
+
Sometimes mid-flow you need to detour into a _different_ journey — e.g. inside checkout the customer needs to verify identity, or inside an integration setup the user needs to add a new credential. The parent journey suspends, the child runs to a terminal, the parent picks up where it left off with the child's terminal payload in hand, and continues. Modular-react models this as a **subroutine**: one new transition primitive (`invoke`) plus a **named resume** handler that fires when the child terminates.
|
|
897
|
+
|
|
898
|
+
The model is deliberately narrow: a parent can have at most one in-flight child per step, the parent's step doesn't change while the child runs, the parent advances only via the resume, and end-ing the parent cascades to the child. If you genuinely need parallel sub-flows (rare), call `runtime.start()` directly and own the bookkeeping — that path remains available.
|
|
899
|
+
|
|
900
|
+
### The shape
|
|
901
|
+
|
|
902
|
+
A transition handler returns an `{ invoke: { handle, input, resume } }` arm instead of `{ next | complete | abort }`. Build it with the typed `invoke()` helper — a bare object literal **does not** cross-check `input` against the handle's `TInput` (the discriminated-union arm declares `InvokeSpec<unknown, unknown>` so the runtime can dispatch on any handle). The parent's journey definition declares a sibling `resumes` map mirroring `transitions`:
|
|
903
|
+
|
|
904
|
+
```ts
|
|
905
|
+
import { defineJourney, defineJourneyHandle, invoke } from "@modular-react/journeys";
|
|
906
|
+
import { verifyIdentityHandle } from "verify-identity-journey";
|
|
907
|
+
// ^ exported from the child's package via defineJourneyHandle.
|
|
908
|
+
|
|
909
|
+
defineJourney<CheckoutModules, CheckoutState, { token: string }>()({
|
|
910
|
+
id: "checkout",
|
|
911
|
+
version: "1.0.0",
|
|
912
|
+
initialState: (input: { orderId: string }) => ({ orderId: input.orderId, token: null }),
|
|
913
|
+
start: (s) => ({ module: "checkout", entry: "review", input: { orderId: s.orderId } }),
|
|
914
|
+
transitions: {
|
|
915
|
+
checkout: {
|
|
916
|
+
review: {
|
|
917
|
+
// Exit handler dispatches an invoke instead of next. `invoke()`
|
|
918
|
+
// threads the handle's TInput / TOutput through to `input` and
|
|
919
|
+
// the resume so a wrong-shaped `input` is a compile error.
|
|
920
|
+
requestPayment: ({ state }) =>
|
|
921
|
+
invoke({
|
|
922
|
+
handle: verifyIdentityHandle, // typed handle, see "Journey handles"
|
|
923
|
+
input: { customerId: state.customerId },
|
|
924
|
+
resume: "afterIdentity", // names the resume below
|
|
925
|
+
}),
|
|
926
|
+
},
|
|
927
|
+
},
|
|
928
|
+
},
|
|
929
|
+
resumes: {
|
|
930
|
+
checkout: {
|
|
931
|
+
review: {
|
|
932
|
+
// outcome is ChildOutcome<TVerifyOutput> — completed has a typed
|
|
933
|
+
// payload; aborted carries reason. Both are surfaced; you decide.
|
|
934
|
+
afterIdentity: ({ state, outcome }) =>
|
|
935
|
+
outcome.status === "completed"
|
|
936
|
+
? {
|
|
937
|
+
state: { ...state, token: outcome.payload.token },
|
|
938
|
+
next: {
|
|
939
|
+
module: "billing",
|
|
940
|
+
entry: "collect",
|
|
941
|
+
input: { orderId: state.orderId, token: outcome.payload.token },
|
|
942
|
+
},
|
|
943
|
+
}
|
|
944
|
+
: { abort: { reason: "identity-failed", cause: outcome.reason } },
|
|
945
|
+
},
|
|
946
|
+
},
|
|
947
|
+
},
|
|
948
|
+
});
|
|
949
|
+
```
|
|
950
|
+
|
|
951
|
+
The child journey is a totally normal `defineJourney` — it doesn't know it's being invoked. It declares its `TInput` (input from the parent) and its `TOutput` (the third generic on `defineJourney<TModules, TState, TOutput>()`, narrowing the type of `complete` payloads). Its handle, exported via `defineJourneyHandle(childJourney)`, carries both as phantom types so `invoke()` checks `input` and the parent's resume sees `outcome.payload` typed end-to-end.
|
|
952
|
+
|
|
953
|
+
### Why named resumes (and not closures)
|
|
954
|
+
|
|
955
|
+
You might expect `invoke` to take a `resume: (ctx) => ...` closure. We deliberately don't — closures don't survive a persistence reload. Naming the resume keeps everything serializable: the parent's blob records `pendingInvoke.resumeName`, the runtime looks up `def.resumes[mod][entry][name]` on hydrate, and the call chain restores exactly. See the persistence section below for the round-trip details.
|
|
956
|
+
|
|
957
|
+
Resume names live in their own keyspace from exit names — the lookup tables are siblings, not the same map. **One authoring rule, enforced at registration time:** a resume name must not collide with any exit name on the **same module** (not the same entry — the broader scope catches the typical "I meant a transition handler, not a resume" mistake). `validateJourneyContracts` rejects the registration with an explicit message naming the offender, so a typo at authoring time fails loudly instead of becoming a silent `invoke-unknown-resume` later.
|
|
958
|
+
|
|
959
|
+
### Lifecycle and edge cases
|
|
960
|
+
|
|
961
|
+
| Situation | Behavior |
|
|
962
|
+
| ------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
|
963
|
+
| Child completes | Parent's named resume fires with `{ status: "completed", payload }` (typed). |
|
|
964
|
+
| Child aborts (its own `{ abort }` or `runtime.end`) | Parent's named resume fires with `{ status: "aborted", reason }`. Author decides whether to recover or propagate. |
|
|
965
|
+
| Parent ended while child active | Cascade — link is severed first, then the child is ended with terminal payload `{ reason: "parent-ended", parentId, cause: <end-reason> }`, then the parent runs its own `onAbandon` and aborts independently. The resume does **not** fire (the link was nulled before the cascade). |
|
|
966
|
+
| Child invokes a grandchild | Same machinery; the resume bubbles up the chain. The runtime maintains parent links and a reverse map; nothing special. |
|
|
967
|
+
| Parent fires an exit while child is in flight | Dropped with a debug warn — the parent advances only via resume. |
|
|
968
|
+
| Invoke names an unknown journey id | Parent aborts immediately with reason `invoke-unknown-journey`. `onError` fires with `phase: "invoke"`. |
|
|
969
|
+
| Invoke names a resume that isn't declared on the current step | Parent aborts with reason `invoke-unknown-resume`. `onError` fires with `phase: "invoke"`. |
|
|
970
|
+
| Resume name vanished between invoke and child terminal (definition upgraded mid-flow) | Parent aborts with reason `resume-missing` (carrying `resume` and `childJourneyId`). `onError` fires with `phase: "resume"`. |
|
|
971
|
+
| Resume handler throws | Parent aborts with reason `resume-threw`, `error` carries the throw. `onError` fires with `phase: "resume"`. |
|
|
972
|
+
| Resume handler returns a Promise | Parent aborts with reason `resume-returned-promise`. Resumes must be sync and pure, like exit handlers. |
|
|
973
|
+
| Hydrate: child blob missing on reload | Parent stays `active` with `activeChildId` set; exits remain blocked. The shell decides whether to load the child later or `runtime.end` the parent to give up. |
|
|
974
|
+
|
|
975
|
+
### Outlet behavior
|
|
976
|
+
|
|
977
|
+
`<JourneyOutlet>` walks the active call chain by default and renders the _leaf_. If the parent has invoked a child, the parent's component disappears and the child's component takes over the same outlet — matching the subroutine intuition. The chain re-renders when any link changes.
|
|
978
|
+
|
|
979
|
+
For a layered presentation (parent visible underneath, child in a modal), pass `leafOnly={false}` on the outer outlet to keep it on the parent's step, and mount a sibling outlet against `instance.activeChildId` (or use `useJourneyCallStack`) for the child:
|
|
980
|
+
|
|
981
|
+
```tsx
|
|
982
|
+
import { JourneyOutlet, useJourneyCallStack } from "@modular-react/journeys";
|
|
983
|
+
|
|
984
|
+
function CheckoutPanel({ instanceId }: { instanceId: InstanceId }) {
|
|
985
|
+
const chain = useJourneyCallStack(runtime, instanceId);
|
|
986
|
+
// `chain[length - 1]` is the active *leaf*. For shallow nesting that's
|
|
987
|
+
// the immediate child; for parent → child → grandchild the modal will
|
|
988
|
+
// surface the grandchild instead. Use `instance.activeChildId` directly
|
|
989
|
+
// if you specifically want the immediate child.
|
|
990
|
+
const leafId = chain.length > 1 ? chain[chain.length - 1] : null;
|
|
991
|
+
return (
|
|
992
|
+
<>
|
|
993
|
+
<JourneyOutlet runtime={runtime} instanceId={instanceId} leafOnly={false} />
|
|
994
|
+
{leafId ? (
|
|
995
|
+
<Modal>
|
|
996
|
+
<JourneyOutlet runtime={runtime} instanceId={leafId} />
|
|
997
|
+
</Modal>
|
|
998
|
+
) : null}
|
|
999
|
+
</>
|
|
1000
|
+
);
|
|
1001
|
+
}
|
|
1002
|
+
```
|
|
1003
|
+
|
|
1004
|
+
`onFinished` on a `<JourneyOutlet>` fires for the **root** instance only — it's the journey the caller mounted. Child terminations are observed via the parent's resume handler, not the outer outlet.
|
|
1005
|
+
|
|
1006
|
+
### Persistence (round-tripping invoke state)
|
|
1007
|
+
|
|
1008
|
+
When a parent has an in-flight child, `serialize()` emits `pendingInvoke` on the parent and `parentLink` on the child:
|
|
1009
|
+
|
|
1010
|
+
```jsonc
|
|
1011
|
+
// parent blob
|
|
1012
|
+
{
|
|
1013
|
+
// ...standard fields
|
|
1014
|
+
"pendingInvoke": {
|
|
1015
|
+
"childJourneyId": "verify-identity",
|
|
1016
|
+
"childInstanceId": "ji_abc",
|
|
1017
|
+
"childPersistenceKey": "verify-identity:cust-42",
|
|
1018
|
+
"resumeName": "afterIdentity"
|
|
1019
|
+
}
|
|
1020
|
+
}
|
|
1021
|
+
|
|
1022
|
+
// child blob
|
|
1023
|
+
{
|
|
1024
|
+
// ...standard fields
|
|
1025
|
+
"parentLink": { "parentInstanceId": "ji_xyz", "resumeName": "afterIdentity" }
|
|
1026
|
+
}
|
|
1027
|
+
```
|
|
1028
|
+
|
|
1029
|
+
`childPersistenceKey` is `string | null` — it's `null` when the child journey has no persistence adapter configured. The parent still tracks `childInstanceId` in memory, but on a process restart the child can't be reloaded from storage and the parent will land in the "child blob missing" state described in the lifecycle table.
|
|
1030
|
+
|
|
1031
|
+
**Auto-rehydrate.** Calling `runtime.start(parentHandle, input)` on a fresh runtime against the same persistence backing will pull the parent blob, see the `pendingInvoke.childPersistenceKey`, and load the child blob automatically — recursing into grandchildren if the child blob carries its own `pendingInvoke`. Shells only need to `start()` the root; the leaf comes back along with its parents.
|
|
1032
|
+
|
|
1033
|
+
After every hydrate path (sync start, async start, explicit `runtime.hydrate`), the runtime relinks every in-memory pair via the `parent` / `activeChildId` fields. Order doesn't matter — hydrate the parent first, then the child, or vice versa; the link reconciles either way. A parent whose `activeChildId` references a not-yet-loaded child stays `active` (exits blocked) until the child arrives.
|
|
1034
|
+
|
|
1035
|
+
If the child's blob is gone for good (TTL expired, manual remove, or no persistence configured on the child), the shell decides recovery: keep the parent suspended, or `runtime.end(parent)` to give up. The runtime does not auto-abort, because that would race with multi-step hydrates that legitimately load the parent first.
|
|
1036
|
+
|
|
1037
|
+
### Telemetry: `TransitionEvent.kind`
|
|
1038
|
+
|
|
1039
|
+
Every event a registered `onTransition` hook receives now carries a `kind` discriminator:
|
|
1040
|
+
|
|
1041
|
+
```ts
|
|
1042
|
+
onTransition: (ev) => {
|
|
1043
|
+
if (ev.kind === "step") metrics.record("journey.hop", { id: ev.journeyId });
|
|
1044
|
+
if (ev.kind === "invoke")
|
|
1045
|
+
metrics.record("journey.invoke", { id: ev.journeyId, child: ev.child?.journeyId });
|
|
1046
|
+
if (ev.kind === "resume")
|
|
1047
|
+
metrics.record("journey.resume", { id: ev.journeyId, resume: ev.resume });
|
|
1048
|
+
};
|
|
1049
|
+
```
|
|
1050
|
+
|
|
1051
|
+
A consumer that only cares about top-level steps filters `kind === "step"`. Otherwise read `ev.child` on invokes and `ev.outcome` / `ev.resume` on resumes.
|
|
1052
|
+
|
|
1053
|
+
### Testing
|
|
1054
|
+
|
|
1055
|
+
The simulator drives both modes:
|
|
1056
|
+
|
|
1057
|
+
```ts
|
|
1058
|
+
import { simulateJourney } from "@modular-react/journeys";
|
|
1059
|
+
|
|
1060
|
+
// Drive a real child sub-simulator end-to-end:
|
|
1061
|
+
const sim = simulateJourney(
|
|
1062
|
+
parentJourney,
|
|
1063
|
+
{ orderId: "O-1" },
|
|
1064
|
+
{
|
|
1065
|
+
children: [verifyIdentityJourney],
|
|
1066
|
+
},
|
|
1067
|
+
);
|
|
1068
|
+
sim.fireExit("requestPayment");
|
|
1069
|
+
sim.activeChild!.fireExit("verified", { token: "T" }); // child runs to terminal
|
|
1070
|
+
expect(sim.currentStep.entry).toBe("collect"); // parent has resumed
|
|
1071
|
+
|
|
1072
|
+
// Or mock the child's outcome to unit-test the parent's resume in isolation:
|
|
1073
|
+
const sim2 = simulateJourney(
|
|
1074
|
+
parentJourney,
|
|
1075
|
+
{ orderId: "O-2" },
|
|
1076
|
+
{
|
|
1077
|
+
children: [verifyIdentityJourney],
|
|
1078
|
+
},
|
|
1079
|
+
);
|
|
1080
|
+
sim2.fireExit("requestPayment");
|
|
1081
|
+
sim2.completeChild({ token: "T-MOCK" }); // synthesize completed
|
|
1082
|
+
expect(sim2.state.token).toBe("T-MOCK");
|
|
1083
|
+
|
|
1084
|
+
sim2.abortChild({ code: "denied" }); // synthesize aborted — reason reaches the resume as-is, no wrap
|
|
1085
|
+
```
|
|
1086
|
+
|
|
1087
|
+
`completeChild` uses the runtime's standard transition machinery — `onComplete`, `onTransition`, persistence, and the parent's resume hook all fire as they would for a real `{ complete }`.
|
|
1088
|
+
|
|
1089
|
+
### What this is _not_
|
|
1090
|
+
|
|
1091
|
+
- **Not shared state.** Each journey owns its `TState`; communication is exclusively via `input` (down) and `outcome.payload` (up). Preserves the mental-model boundary.
|
|
1092
|
+
- **Not concurrent spawn.** A parent has at most one active invocation. If you need parallel children, call `runtime.start()` directly and store ids in state — but you give up the typed resume linking, the persisted `pendingInvoke` / `parentLink` round-trip, the `activeChildId` chain that `<JourneyOutlet>` walks, and the cascade-end semantics. Everything becomes shell-managed.
|
|
1093
|
+
- **Not back-navigation across the boundary.** `goBack` stays scoped to a journey's own history. To return from a child without completing, fire an exit that maps to `{ abort }`; the parent's resume handler decides what to do.
|
|
1094
|
+
|
|
1095
|
+
### Cycle and recursion safety
|
|
1096
|
+
|
|
1097
|
+
Composing journeys creates a _call graph_ — A invokes B, which invokes C, possibly back into A. Without guards, a cycle becomes either an infinite chain that exhausts memory or a same-step bouncing resume that pegs the CPU. Modular-react ships **four layered guards**, three at runtime (always on) plus an opt-in static check at registration time. Every guard surfaces through the existing `onError` channel with `phase: "invoke"` (or `"resume"`) and aborts the offending parent with a discoverable reason — same vocabulary as the other validation aborts.
|
|
1098
|
+
|
|
1099
|
+
| Guard | When it fires | Abort reason | Default |
|
|
1100
|
+
| --------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------- | ------------------------------------ |
|
|
1101
|
+
| **Static cycle detection** | Registration time, when every journey on the cycle path declares its `invokes` set. Throws `JourneyValidationError` listing the cycle path (e.g. `cycle detected: "A" → "B" → "A"`). | (registration error) | always on when `invokes` is declared |
|
|
1102
|
+
| **invoke-undeclared-child** | Runtime invoke time, when the parent journey declared an `invokes` array but the dispatched handle id is not in it. Catches dynamic-dispatch typos. | `invoke-undeclared-child` | always on when `invokes` is declared |
|
|
1103
|
+
| **invoke-cycle** | Runtime invoke time, when the target journey id already appears on the active parent chain. Catches cycles that the static check missed (because some journey on the cycle omitted `invokes`). | `invoke-cycle` | always on |
|
|
1104
|
+
| **invoke-stack-overflow** | Runtime invoke time, when admitting the new child would push the chain depth past the resolved cap. The cap is the **minimum** of every non-undefined `maxCallStackDepth` across the active chain (ancestors + parent + would-be child). | `invoke-stack-overflow` | `maxCallStackDepth: 16` |
|
|
1105
|
+
| **resume-bounce-limit** | Runtime resume time, when a resume returns `{ invoke }` for the Nth consecutive time at the _same parent step_ (the counter resets when the parent's step actually advances). | `resume-bounce-limit` | `maxResumeBouncesPerStep: 8` |
|
|
1106
|
+
|
|
1107
|
+
#### Declaring the call set (`invokes`)
|
|
1108
|
+
|
|
1109
|
+
The strongest line of defense is opt-in: list every child handle the journey can dispatch to.
|
|
1110
|
+
|
|
1111
|
+
```ts
|
|
1112
|
+
import { defineJourney, defineJourneyHandle, invoke } from "@modular-react/journeys";
|
|
1113
|
+
import { verifyIdentityHandle } from "verify-identity-journey";
|
|
1114
|
+
import { addPaymentMethodHandle } from "add-payment-method-journey";
|
|
1115
|
+
|
|
1116
|
+
defineJourney<CheckoutModules, CheckoutState>()({
|
|
1117
|
+
id: "checkout",
|
|
1118
|
+
version: "1.0.0",
|
|
1119
|
+
// Closed set — the runtime rejects any other handle at invoke time, and
|
|
1120
|
+
// the registry runs cycle detection across the declared graph at boot.
|
|
1121
|
+
invokes: [verifyIdentityHandle, addPaymentMethodHandle],
|
|
1122
|
+
// ...transitions / resumes that may dispatch invoke() to either handle.
|
|
1123
|
+
});
|
|
1124
|
+
```
|
|
1125
|
+
|
|
1126
|
+
When **every** journey in a registration declares `invokes`, the registry's `validateJourneyContracts` (which calls `validateJourneyGraph` internally before its own structural checks) builds the full directed graph and rejects the whole registration on a cycle:
|
|
1127
|
+
|
|
1128
|
+
```
|
|
1129
|
+
JourneyValidationError: Invalid journey registration:
|
|
1130
|
+
- journey invoke cycle detected: "checkout" → "verify-identity" → "checkout"
|
|
1131
|
+
```
|
|
1132
|
+
|
|
1133
|
+
The cycle check is **already part of the standard registry validation pipeline** — you do not need to opt in. Authors who want to run the graph check by hand (e.g. while composing registrations across plugin boundaries before handing them to the registry) can call `validateJourneyGraph(journeys)` directly; both functions throw `JourneyValidationError` with the same per-cycle message format.
|
|
1134
|
+
|
|
1135
|
+
When some journeys omit `invokes`, the static check is incomplete (their out-edges are unknown), so the runtime guards remain the safety net. There is no penalty for omitting `invokes`; it's purely a confidence dial.
|
|
1136
|
+
|
|
1137
|
+
#### Tuning `maxCallStackDepth`
|
|
1138
|
+
|
|
1139
|
+
Set on the journey's registration options. Any journey in the chain can lower the cap; none can raise it. Setting it to `1` blocks `invoke` from this journey outright (useful for "leaf" journeys that should never spawn children).
|
|
1140
|
+
|
|
1141
|
+
```ts
|
|
1142
|
+
registry.registerJourney(checkoutJourney, {
|
|
1143
|
+
maxCallStackDepth: 4, // checkout is happy to host up to 3 nested children
|
|
1144
|
+
persistence: ...,
|
|
1145
|
+
});
|
|
1146
|
+
```
|
|
1147
|
+
|
|
1148
|
+
The resolved cap on each `invoke` is `min(non-undefined options across [ancestors..., parent, child])`, falling back to `16`. The strictest journey in the chain wins, which means a cautious utility journey can lower the cap for any composition that includes it without coordinating with the other journeys.
|
|
1149
|
+
|
|
1150
|
+
`0`, negative, or non-finite values are treated as "no opinion" (consistent with `maxHistory`) so a misconfigured `0` cannot silently disable the guard.
|
|
1151
|
+
|
|
1152
|
+
#### Tuning `maxResumeBouncesPerStep`
|
|
1153
|
+
|
|
1154
|
+
A "bounce" is a resume that returns `{ invoke }` instead of advancing the parent's step. Counted per-parent, scoped to the parent's current step (not the parent's instance) — so a flow that legitimately retries a sub-flow several times in a row is fine, as long as the parent eventually advances. The counter resets whenever the parent's step actually changes (`{ next | complete | abort }` from any source).
|
|
1155
|
+
|
|
1156
|
+
```ts
|
|
1157
|
+
registry.registerJourney(checkoutJourney, {
|
|
1158
|
+
maxResumeBouncesPerStep: 3, // verify, fail, retry, fail, retry → abort
|
|
1159
|
+
});
|
|
1160
|
+
```
|
|
1161
|
+
|
|
1162
|
+
The bounce cap is per-parent — only the parent's own option governs (children don't see the parent's resumes and have no business voting). The counter is **persisted on the parent's blob** as `resumeBouncesAtStep`, so a hostile or accidental reload-bounce-reload-bounce sequence cannot reset the budget through storage. `0`, negative, or non-finite values fall through to the library default of `8`.
|
|
1163
|
+
|
|
1164
|
+
#### Failure surface
|
|
1165
|
+
|
|
1166
|
+
All four guards abort the offending parent with a structured `terminalPayload` that's safe to log. The shapes:
|
|
1167
|
+
|
|
1168
|
+
```ts
|
|
1169
|
+
// invoke-undeclared-child
|
|
1170
|
+
{ reason: "invoke-undeclared-child", parentJourneyId: "...", childJourneyId: "...", exit: "..." }
|
|
1171
|
+
|
|
1172
|
+
// invoke-cycle (chain mirrors the printed warning — cycle portion only,
|
|
1173
|
+
// pre-cycle prefix dropped, duplicate target id appears at both ends)
|
|
1174
|
+
{ reason: "invoke-cycle", childJourneyId: "B", chain: ["B", "C", "D", "B"], exit: "..." }
|
|
1175
|
+
|
|
1176
|
+
// invoke-stack-overflow (chain is the full ancestors → parent → child)
|
|
1177
|
+
{ reason: "invoke-stack-overflow", depth: 17, cap: 16, chain: ["a", "b", ..., "p"], exit: "..." }
|
|
1178
|
+
|
|
1179
|
+
// resume-bounce-limit
|
|
1180
|
+
{ reason: "resume-bounce-limit", cap: 8, count: 9, resume: "afterChild" }
|
|
1181
|
+
```
|
|
1182
|
+
|
|
1183
|
+
Each one fires the registration's `onError` first (with `phase: "invoke"` for the first three, `phase: "resume"` for the last), so telemetry still observes the underlying control-plane failure even when the abort itself is what reaches the user.
|
|
1184
|
+
|
|
1185
|
+
**Typed narrowing.** The four guards above plus every other runtime-emitted abort (`invoke-unknown-journey`, `invoke-unknown-resume`, `resume-missing`, `resume-threw`, `transition-error`, …) share a single discriminated union, `JourneySystemAbortReason`. Pair it with the `isJourneySystemAbort` predicate to narrow against author-supplied aborts (`{ abort: "user-cancelled" }`, `{ abort: { reason: "user-thing" } }`, etc.), which the predicate excludes by checking the `reason` against the closed set of system codes:
|
|
1186
|
+
|
|
1187
|
+
```ts
|
|
1188
|
+
import { isJourneySystemAbort } from "@modular-react/journeys";
|
|
1189
|
+
|
|
1190
|
+
resumes: {
|
|
1191
|
+
checkout: {
|
|
1192
|
+
review: {
|
|
1193
|
+
afterIdentity: ({ outcome }) => {
|
|
1194
|
+
if (outcome.status !== "aborted") return /* ... */;
|
|
1195
|
+
if (isJourneySystemAbort(outcome.reason)) {
|
|
1196
|
+
// outcome.reason is now the discriminated union — switch on `reason`
|
|
1197
|
+
// for typed access to per-arm fields:
|
|
1198
|
+
switch (outcome.reason.reason) {
|
|
1199
|
+
case "invoke-cycle":
|
|
1200
|
+
metrics.record("invoke_cycle", { chain: outcome.reason.chain });
|
|
1201
|
+
break;
|
|
1202
|
+
case "resume-bounce-limit":
|
|
1203
|
+
metrics.record("bounce_limit", { cap: outcome.reason.cap });
|
|
1204
|
+
break;
|
|
1205
|
+
}
|
|
1206
|
+
} else {
|
|
1207
|
+
// author-supplied abort (e.g. `{ abort: { code: "denied" } }`) —
|
|
1208
|
+
// narrow as you would any other unknown payload.
|
|
1209
|
+
}
|
|
1210
|
+
return { abort: outcome.reason };
|
|
1211
|
+
},
|
|
1212
|
+
},
|
|
1213
|
+
},
|
|
1214
|
+
},
|
|
1215
|
+
```
|
|
1216
|
+
|
|
1217
|
+
The `JourneySystemAbortReasonCode` literal-string union is also exported if you only need the code list (e.g. for a `Set` membership check or a switch over an external classification).
|
|
1218
|
+
|
|
1219
|
+
## Runtime surface
|
|
1220
|
+
|
|
1221
|
+
`manifest.journeys` implements `JourneyRuntime`:
|
|
1222
|
+
|
|
1223
|
+
```ts
|
|
1224
|
+
interface JourneyRuntime {
|
|
1225
|
+
/**
|
|
1226
|
+
* Handle form (preferred) - `input` is type-checked against the handle's
|
|
1227
|
+
* phantom `TInput`. See "Journey handles" below for the pattern.
|
|
1228
|
+
*/
|
|
1229
|
+
start<TId extends string, TInput>(
|
|
1230
|
+
handle: JourneyHandleRef<TId, TInput>,
|
|
1231
|
+
input: TInput,
|
|
1232
|
+
): InstanceId;
|
|
1233
|
+
/**
|
|
1234
|
+
* String-id form - accepts any `input`. Useful for dynamic dispatch
|
|
1235
|
+
* where the id only exists at runtime (e.g. a navbar action carrying
|
|
1236
|
+
* `{ kind: "journey-start", journeyId }`).
|
|
1237
|
+
*/
|
|
1238
|
+
start<TInput>(journeyId: string, input: TInput): InstanceId;
|
|
1239
|
+
|
|
1240
|
+
/**
|
|
1241
|
+
* Explicit hydrate from a caller-supplied blob. Persistence-unlinked:
|
|
1242
|
+
* the hydrated instance doesn't claim a persistence key and won't be
|
|
1243
|
+
* saved back. Useful for read-only audit/replay views.
|
|
1244
|
+
*/
|
|
1245
|
+
hydrate<TState>(journeyId: string, blob: SerializedJourney<TState>): InstanceId;
|
|
1246
|
+
|
|
1247
|
+
getInstance(id: InstanceId): JourneyInstance | null;
|
|
1248
|
+
listInstances(): readonly InstanceId[];
|
|
1249
|
+
listDefinitions(): readonly JourneyDefinitionSummary[];
|
|
1250
|
+
|
|
1251
|
+
/**
|
|
1252
|
+
* Cheap predicate for "is this journey id known to this runtime?"
|
|
1253
|
+
* Useful when rehydrating persisted shell state (tabs, task queue, …)
|
|
1254
|
+
* to drop entries for journeys renamed or removed between deploys -
|
|
1255
|
+
* avoids routing expected drops through the `UnknownJourneyError`
|
|
1256
|
+
* exception channel.
|
|
1257
|
+
*/
|
|
1258
|
+
isRegistered(journeyId: string): boolean;
|
|
1259
|
+
|
|
1260
|
+
/** Subscribe to changes on one instance. Returns unsubscribe. */
|
|
1261
|
+
subscribe(id: InstanceId, listener: () => void): () => void;
|
|
1262
|
+
|
|
1263
|
+
/**
|
|
1264
|
+
* Force-terminate an instance. Fires `onAbandon` if still active;
|
|
1265
|
+
* no-op if already terminal or unknown.
|
|
1266
|
+
*/
|
|
1267
|
+
end(id: InstanceId, reason?: unknown): void;
|
|
1268
|
+
|
|
1269
|
+
/** Drop a terminal instance from memory. No-op on active/loading. */
|
|
1270
|
+
forget(id: InstanceId): void;
|
|
1271
|
+
|
|
1272
|
+
/** Drop every terminal instance in one call. Returns the drop count. */
|
|
1273
|
+
forgetTerminal(): number;
|
|
1274
|
+
}
|
|
1275
|
+
```
|
|
1276
|
+
|
|
1277
|
+
Both `start` overloads resolve to the same runtime call; the handle form only exists to type-check `input`. Prefer handles in new code - see [Journey handles](#journey-handles) for the full pattern.
|
|
1278
|
+
|
|
1279
|
+
### When to call which
|
|
1280
|
+
|
|
1281
|
+
| Situation | Use |
|
|
1282
|
+
| --------------------------------------------------------------------- | ---------------------------------------------------------------- |
|
|
1283
|
+
| User clicks "start customer onboarding". | `runtime.start(onboardingHandle, { customerId })` - handle form. |
|
|
1284
|
+
| Dynamic dispatch (navbar action / command palette with an opaque id). | `runtime.start(action.journeyId, input)` - string-id form. |
|
|
1285
|
+
| Reloading the shell and restoring tabs from localStorage. | `runtime.start(…)` again - persistence resumes. |
|
|
1286
|
+
| Filter persisted shell state before calling `start()`. | `runtime.isRegistered(journeyId)` - cheap pre-check. |
|
|
1287
|
+
| Read-only "show me what this journey looked like in audit log #1234". | `runtime.hydrate(journeyId, blob)` - no persistence. |
|
|
1288
|
+
| Shell wants to react to state changes (tab title, breadcrumb). | `runtime.subscribe(id, listener)` |
|
|
1289
|
+
| User closes a journey tab before it completes. | Let `<JourneyOutlet>` unmount - it calls `end()`. |
|
|
1290
|
+
| Shell explicitly cancels (e.g. "end shift"). | `runtime.end(id, { reason: 'end-of-shift' })` |
|
|
1291
|
+
| Long-running workspace accumulated finished journeys; free memory. | `runtime.forgetTerminal()` |
|
|
1292
|
+
| After `onFinished`, prune this specific terminal instance. | `runtime.forget(id)` |
|
|
1293
|
+
|
|
1294
|
+
### `listDefinitions()` and `listInstances()`
|
|
1295
|
+
|
|
1296
|
+
Primarily useful for diagnostics, command palettes, or admin tooling. A "launch journey" picker can render `runtime.listDefinitions()` directly; a "which journeys are open for this user" debug panel can walk `runtime.listInstances()` and `getInstance(id)`.
|
|
1297
|
+
|
|
1298
|
+
### Journey handles
|
|
1299
|
+
|
|
1300
|
+
A **journey handle** is a typed token a journey package exports so shells and modules can open it with a correctly-shaped `input` without importing the journey's runtime code. Export one per journey:
|
|
1301
|
+
|
|
1302
|
+
```ts
|
|
1303
|
+
// journeys/customer-onboarding/src/customer-onboarding.ts
|
|
1304
|
+
import { defineJourney, defineJourneyHandle } from "@modular-react/journeys";
|
|
1305
|
+
|
|
1306
|
+
export const customerOnboardingJourney = defineJourney<Modules, State>()({
|
|
1307
|
+
id: "customer-onboarding",
|
|
1308
|
+
version: "1.0.0",
|
|
1309
|
+
initialState: ({ customerId }: { customerId: string }) => ({
|
|
1310
|
+
/* … */
|
|
1311
|
+
}),
|
|
1312
|
+
/* … */
|
|
1313
|
+
});
|
|
1314
|
+
|
|
1315
|
+
// Publish a handle alongside the journey definition - same package, same file.
|
|
1316
|
+
export const customerOnboardingHandle = defineJourneyHandle(customerOnboardingJourney);
|
|
1317
|
+
```
|
|
1318
|
+
|
|
1319
|
+
At the call site, pass the handle to `runtime.start`:
|
|
1320
|
+
|
|
1321
|
+
```ts
|
|
1322
|
+
import { customerOnboardingHandle } from "@myorg/journey-customer-onboarding";
|
|
1323
|
+
|
|
1324
|
+
const instanceId = runtime.start(customerOnboardingHandle, { customerId: "C-1" });
|
|
1325
|
+
// input is type-checked end-to-end - wrong shape = compile error.
|
|
1326
|
+
```
|
|
1327
|
+
|
|
1328
|
+
`defineJourneyHandle(def)` returns `{ id: def.id }` at runtime; the input-type check lives entirely in the type system (the `__input` field is phantom - no value, never read). This is why modules and shells can `import type`-only from a journey package and still get full `input` checking without pulling the journey definition into their bundle.
|
|
1329
|
+
|
|
1330
|
+
Why handles exist:
|
|
1331
|
+
|
|
1332
|
+
- **No runtime coupling.** A module that launches a journey imports the handle via `import type` - the journey's transition code never enters the module's bundle.
|
|
1333
|
+
- **Type-safe `input`.** The handle carries `TInput` as a phantom; `runtime.start(handle, input)` is the overload that type-checks it. The string-id form accepts any input and is the right call only for dynamic dispatch (e.g. a nav action carrying an opaque `journeyId`).
|
|
1334
|
+
- **Single canonical id.** `handle.id === def.id` at runtime; dedup/lookup code can compare handles or ids interchangeably without casing on which one it received.
|
|
1335
|
+
|
|
1336
|
+
The string-id `start()` overload stays supported precisely because plugin-contributed nav items carry `{ kind: "journey-start", journeyId }` - a dispatcher can't hold a handle reference for every registered journey, so it falls back to the string form.
|
|
1337
|
+
|
|
1338
|
+
## `JourneyProvider` + context
|
|
1339
|
+
|
|
1340
|
+
`JourneyProvider` supplies the runtime (and an optional `onModuleExit` fallback) to descendant `<JourneyOutlet>` and `<ModuleTab>` nodes via context. Mount it once at the top of the shell:
|
|
1341
|
+
|
|
1342
|
+
```tsx
|
|
1343
|
+
<JourneyProvider runtime={manifest.journeys} onModuleExit={manifest.onModuleExit}>
|
|
1344
|
+
<AppRoutes />
|
|
1345
|
+
</JourneyProvider>
|
|
1346
|
+
```
|
|
1347
|
+
|
|
1348
|
+
Explicit `runtime` / `modules` props on `<JourneyOutlet>` still win - useful when a single tree needs to reach two distinct runtimes (split-screen agents, multi-tenant dashboards). `useJourneyContext()` exposes the current value (or `null` when no provider is mounted) for shells that need the runtime for non-React-rendering work - e.g. opening a new tab from a command-palette handler.
|
|
1349
|
+
|
|
1350
|
+
Because `useJourneyContext()` can return `null`, examples that use the non-null assertion (`useJourneyContext()!.runtime`) are only safe **inside a tree where the provider is guaranteed to be mounted** - typically the subtree below `<JourneyProvider>` in your shell. In code paths that can legitimately run outside the provider (e.g. a shared utility callable from both journey-aware and journey-unaware hosts), null-check the return value instead and fall back to whatever the caller supplied.
|
|
1351
|
+
|
|
1352
|
+
## Persistence
|
|
1353
|
+
|
|
1354
|
+
**Persistence is optional.** Skip it and journeys live in memory only - every `runtime.start()` mints a fresh instance and nothing is written to storage. Add an adapter when you want reload recovery (resuming after a refresh) or idempotent `start` (the same input returning the same `instanceId`).
|
|
1355
|
+
|
|
1356
|
+
When you do want it, plug an adapter in at registration. The preferred shape is `defineJourneyPersistence<TInput, TState>` - it types `keyFor`'s `input` against the journey's `TInput` and `load` / `save` against its `TState`, so there's no `as` cast at the call site:
|
|
1357
|
+
|
|
1358
|
+
```ts
|
|
1359
|
+
import { defineJourneyPersistence } from "@modular-react/journeys";
|
|
1360
|
+
|
|
1361
|
+
registry.registerJourney(journey, {
|
|
1362
|
+
persistence: defineJourneyPersistence<OnboardingInput, OnboardingState>({
|
|
1363
|
+
keyFor: ({ journeyId, input }) => `journey:${input.customerId}:${journeyId}`,
|
|
1364
|
+
load: (key) => backend.loadJourney(key),
|
|
1365
|
+
save: (key, blob) => backend.saveJourney(key, blob),
|
|
1366
|
+
remove: (key) => backend.deleteJourney(key),
|
|
1367
|
+
}),
|
|
1368
|
+
});
|
|
1369
|
+
```
|
|
1370
|
+
|
|
1371
|
+
A plain object matching `JourneyPersistence<TState>` still works if you'd rather not use the helper.
|
|
1372
|
+
|
|
1373
|
+
### Stock adapters: `createWebStoragePersistence` and `createMemoryPersistence`
|
|
1374
|
+
|
|
1375
|
+
Two factories ship with the package so common setups don't have to reimplement the same 20 lines of SSR guards and JSON handling. Both return values satisfying `JourneyPersistence<TState, TInput>` - pass them directly to `registerJourney({ persistence })`.
|
|
1376
|
+
|
|
1377
|
+
**`createWebStoragePersistence` - the 80% case (localStorage / sessionStorage).** Backed by the synchronous Web Storage API, SSR-safe, cleans up corrupt entries on read. Matches the sizing profile of most journey state (a few KB of JSON, per user, read-on-mount):
|
|
1378
|
+
|
|
1379
|
+
```ts
|
|
1380
|
+
import { createWebStoragePersistence } from "@modular-react/journeys";
|
|
1381
|
+
|
|
1382
|
+
// Defaults to localStorage (or no-ops under SSR).
|
|
1383
|
+
export const journeyPersistence = createWebStoragePersistence<OnboardingInput, OnboardingState>({
|
|
1384
|
+
keyFor: ({ journeyId, input }) => `journey:${input.customerId}:${journeyId}`,
|
|
1385
|
+
});
|
|
1386
|
+
|
|
1387
|
+
registry.registerJourney(customerOnboardingJourney, { persistence: journeyPersistence });
|
|
1388
|
+
```
|
|
1389
|
+
|
|
1390
|
+
Under the hood: `load` runs `JSON.parse` with a catch that calls `removeItem(key)` so a single bad write doesn't wedge future loads; `save` runs `JSON.stringify` and lets `QuotaExceededError` bubble so the app can surface it; all three methods no-op when `storage` resolves to `null`.
|
|
1391
|
+
|
|
1392
|
+
You can swap the backing store by passing a `storage` option:
|
|
1393
|
+
|
|
1394
|
+
```ts
|
|
1395
|
+
// Tab-scoped - state dies with the tab.
|
|
1396
|
+
createWebStoragePersistence<MyInput, MyState>({
|
|
1397
|
+
keyFor: ({ journeyId, input }) => `s:${input.id}:${journeyId}`,
|
|
1398
|
+
storage: typeof sessionStorage !== "undefined" ? sessionStorage : null,
|
|
1399
|
+
});
|
|
1400
|
+
|
|
1401
|
+
// Lazy getter - re-evaluated per call. Useful when storage availability can
|
|
1402
|
+
// flip after hydration (feature-detect then flip a flag).
|
|
1403
|
+
createWebStoragePersistence<MyInput, MyState>({
|
|
1404
|
+
keyFor: ({ journeyId, input }) => `j:${input.id}:${journeyId}`,
|
|
1405
|
+
storage: () => (canUseStorage() ? localStorage : null),
|
|
1406
|
+
});
|
|
1407
|
+
```
|
|
1408
|
+
|
|
1409
|
+
Pick this adapter unless your state is large (>~1 MB per origin), you need offline-first guarantees, or **concurrent tabs writing the same key** is a correctness concern - the synchronous Web Storage API has no cross-tab write coordination, so the last `save` wins and can silently clobber a concurrent transition from another tab. For those cases, write a custom IndexedDB adapter against the same `JourneyPersistence` interface.
|
|
1410
|
+
|
|
1411
|
+
**`createMemoryPersistence` - for tests and SSR.** `Map`-backed, zero IO. The primary use case is tests: a fresh store per test avoids bleed between cases, and the runtime's persistence code paths stay exercised without `localStorage` mocks.
|
|
1412
|
+
|
|
1413
|
+
```ts
|
|
1414
|
+
import { createMemoryPersistence } from "@modular-react/journeys";
|
|
1415
|
+
|
|
1416
|
+
const store = createMemoryPersistence<OnboardingInput, OnboardingState>({
|
|
1417
|
+
keyFor: ({ journeyId, input }) => `${journeyId}:${input.customerId}`,
|
|
1418
|
+
});
|
|
1419
|
+
|
|
1420
|
+
// Pre-seed for a resume test - the runtime finds the blob on first start():
|
|
1421
|
+
const seeded = createMemoryPersistence<OnboardingInput, OnboardingState>({
|
|
1422
|
+
keyFor: ({ journeyId, input }) => `${journeyId}:${input.customerId}`,
|
|
1423
|
+
initial: [["onboarding:C-1", existingBlob]],
|
|
1424
|
+
});
|
|
1425
|
+
|
|
1426
|
+
// Test-only helpers (not on JourneyPersistence) - useful for assertions:
|
|
1427
|
+
expect(store.size()).toBe(1);
|
|
1428
|
+
expect(store.entries()).toMatchObject([...]);
|
|
1429
|
+
store.clear();
|
|
1430
|
+
```
|
|
1431
|
+
|
|
1432
|
+
Blobs are deep-cloned on both `save` and `load` by default so mutating the stored or returned object can't corrupt the other. Pass `clone: false` only in hot test loops where you've verified nobody mutates the blob.
|
|
1433
|
+
|
|
1434
|
+
Also valid as an SSR "persistence is configured but nothing survives the request" mode: no server state leaks into rendered HTML, and `start()` on the client re-probes from scratch. For an SSR shell that wants real client-side persistence, pick the adapter based on where the code runs - `createMemoryPersistence` on the server, `createWebStoragePersistence` on the client - so the server render produces no cross-request state and the client picks up from `localStorage` as normal.
|
|
1435
|
+
|
|
1436
|
+
Guarantees:
|
|
1437
|
+
|
|
1438
|
+
- **Idempotent `start`** - two `runtime.start(journeyId, input)` calls yielding the same `keyFor` return the same `instanceId`. Useful for reload recovery (same customer → same active journey). The key is namespaced internally by `journeyId`, so two journeys whose `keyFor` happens to return the same string can't alias onto the same instance.
|
|
1439
|
+
- **Saves are serialized per instance** - at most one `save()` in flight; follow-up changes coalesce into a single pending save. Errors are logged but never block a transition.
|
|
1440
|
+
- **Automatic cleanup of dead blobs** - when `start()` reads a terminal / corrupt / unmigrateable blob, the runtime calls `remove(key)` before minting a fresh instance. `remove` is also called when an active instance transitions to `completed` / `aborted`.
|
|
1441
|
+
- **`remove` waits for in-flight `save`** - a terminal transition that fires while a `save()` is still in flight defers the `remove()` until the save settles, so adapters that don't serialize their own ops can't see a `save` land _after_ a `remove` and leave an orphaned blob.
|
|
1442
|
+
- **Bulk terminal cleanup** - `runtime.forgetTerminal()` drops every terminal instance from memory in one call. Useful for long-running workspaces that accumulate finished journeys over a session.
|
|
1443
|
+
|
|
1444
|
+
### Key design - picking the right `keyFor`
|
|
1445
|
+
|
|
1446
|
+
`keyFor({ journeyId, input })` is the **only** identity contract the runtime has with your storage. Get it right and reload recovery is automatic; get it wrong and journeys alias onto each other's state.
|
|
1447
|
+
|
|
1448
|
+
Common shapes:
|
|
1449
|
+
|
|
1450
|
+
| Scope | `keyFor` | Effect |
|
|
1451
|
+
| ---------------------------------- | ------------------------------------------------------------------ | ------------------------------------------------------------------------------------------------ |
|
|
1452
|
+
| One journey per customer | `` `journey:${input.customerId}:${journeyId}` `` | Reload resumes. Opening the same customer's journey twice = same tab/instance. |
|
|
1453
|
+
| One journey per session | include a session id in `input` and the key | Each agent shift gets a fresh slate; different shifts don't collide. |
|
|
1454
|
+
| One journey per (customer, matter) | `` `journey:${input.customerId}:${input.matterId}:${journeyId}` `` | Supports concurrent journeys for the same customer on distinct matters. |
|
|
1455
|
+
| Strictly per start | include a `nonce` in `input` and the key | Never resumes; every `start()` is a new journey. Use when the semantic is "new flow every time". |
|
|
1456
|
+
|
|
1457
|
+
`keyFor` deliberately does **not** receive `instanceId` - probing happens before one exists, and mixing the two forms has historically produced subtle key mismatches.
|
|
1458
|
+
|
|
1459
|
+
The runtime namespaces keys internally by `journeyId`, so two **different** journeys whose `keyFor` happens to return the same string still resolve to distinct instances - a journey you register in a shared shell can't be aliased onto an unrelated journey's storage by accident. The adapter sees only the user-defined portion of the key; the internal namespace never reaches your storage calls.
|
|
1460
|
+
|
|
1461
|
+
### Sync vs async `load`
|
|
1462
|
+
|
|
1463
|
+
`load()` may return `SerializedJourney | null` synchronously **or** a `Promise<SerializedJourney | null>`. The runtime accommodates both:
|
|
1464
|
+
|
|
1465
|
+
- **Sync** (the onboarding example's localStorage adapter): the instance transitions straight from the initial state to `active` on the same tick. `<JourneyOutlet>`'s `loadingFallback` is never visible.
|
|
1466
|
+
- **Async** (backend adapters): the instance is minted in `status: 'loading'` with state seeded from `initialState(input)` (so consumers never see `undefined` state), then either hydrated from the resolved blob or transitioned to a fresh start if the probe returns `null` / a terminal / a corrupt blob.
|
|
1467
|
+
|
|
1468
|
+
### Save queue
|
|
1469
|
+
|
|
1470
|
+
`save()` is serialized **per instance**:
|
|
1471
|
+
|
|
1472
|
+
- At most one `save()` in flight per instance.
|
|
1473
|
+
- Concurrent state changes during a save coalesce into a single "next save" slot (later changes overwrite earlier pending saves).
|
|
1474
|
+
- `remove()` on a terminal transition cancels any queued save.
|
|
1475
|
+
- Errors are caught and logged; the instance stays in memory and continues accepting transitions.
|
|
1476
|
+
|
|
1477
|
+
This guarantees your backend never sees out-of-order writes for one journey, even with rapid clicks and slow IO.
|
|
1478
|
+
|
|
1479
|
+
### Explicit `runtime.hydrate` vs `runtime.start`
|
|
1480
|
+
|
|
1481
|
+
They serve different purposes:
|
|
1482
|
+
|
|
1483
|
+
| You want to… | Call |
|
|
1484
|
+
| ------------------------------------------------------------------ | --------------------------------------------------------------- |
|
|
1485
|
+
| Open / resume a live journey for a user, with persistence wiring. | `runtime.start(id, input)` |
|
|
1486
|
+
| Render a **read-only** audit/replay view of a stored blob. | `runtime.hydrate(id, blob)` |
|
|
1487
|
+
| Inspect a completed journey from an audit log without resuming it. | `runtime.hydrate(id, blob)` - terminal blobs are accepted here. |
|
|
1488
|
+
|
|
1489
|
+
`hydrate` is **persistence-unlinked**: the instance is created with no persistence key, so no save happens when its state changes (there's nothing for state to change into anyway on a terminal blob). If you genuinely want to resume a non-live blob, delete the storage record and let `start()` mint a fresh one.
|
|
1490
|
+
|
|
1491
|
+
`hydrate` of a terminal blob also **does not fire `onComplete` / `onAbort`** - those already fired on the original live run when the blob was produced, and firing them again on audit replay would double-count analytics. `onTransition` is silent for the same reason. If you need a signal that a terminal hydrate occurred, observe `runtime.getInstance(id).status` directly after the call returns.
|
|
1492
|
+
|
|
1493
|
+
### Versioning
|
|
1494
|
+
|
|
1495
|
+
Every serialized blob carries the journey's `version`. On hydrate:
|
|
1496
|
+
|
|
1497
|
+
- **Default (strict):** throw `JourneyHydrationError` if `blob.version !== definition.version`. The error message names "version mismatch".
|
|
1498
|
+
- **With `onHydrate`:** the hook receives the loaded blob and returns the blob to use (possibly after migration). Throwing from `onHydrate` aborts the hydrate. The wrapped error names `onHydrate` (so callers can distinguish a migrator bug from a true version mismatch) and the original throw is preserved on `.cause` for logging / re-raising.
|
|
1499
|
+
|
|
1500
|
+
`runtime.start()` (the persistence-aware path) treats both failure modes the same: the stale blob is discarded via `persistence.remove(key)` and a fresh instance is minted under the same key. The distinction matters for `runtime.hydrate()`, where the caller is the one deciding what to do with the error.
|
|
1501
|
+
|
|
1502
|
+
Always supply `onHydrate` in production apps that ship new journey versions over time. A minimal pattern:
|
|
1503
|
+
|
|
1504
|
+
```ts
|
|
1505
|
+
onHydrate: (blob) => {
|
|
1506
|
+
switch (blob.version) {
|
|
1507
|
+
case "1.0.0":
|
|
1508
|
+
return blob;
|
|
1509
|
+
case "0.9.0":
|
|
1510
|
+
return migrateFrom09(blob);
|
|
1511
|
+
default:
|
|
1512
|
+
throw new JourneyHydrationError(`unknown version ${blob.version}`);
|
|
1513
|
+
}
|
|
1514
|
+
};
|
|
1515
|
+
```
|
|
1516
|
+
|
|
1517
|
+
If migration would be destructive or the blob is no longer trusted, let `onHydrate` throw - the runtime discards the blob via `persistence.remove(key)` and mints a fresh instance under the same key. That's usually preferable to resuming into a malformed state.
|
|
1518
|
+
|
|
1519
|
+
### Rehydrating shell-level work (tabs, task queues, drafts)
|
|
1520
|
+
|
|
1521
|
+
Shells that persist user work outside the journey (tabs pointing at journey instances, a task queue, a draft list) need a rehydration pass on boot. The shape is inherently app-specific - every shell has a different "persisted work" concept - so the runtime doesn't ship a helper. Write the loop yourself, but discriminate failure modes:
|
|
1522
|
+
|
|
1523
|
+
```ts
|
|
1524
|
+
import { UnknownJourneyError } from "@modular-react/journeys";
|
|
1525
|
+
|
|
1526
|
+
for (const tab of persistedTabs) {
|
|
1527
|
+
if (!journeys.isRegistered(tab.journeyId)) {
|
|
1528
|
+
// The journey was renamed or removed between deploys. Expected after
|
|
1529
|
+
// version skew; drop the tab cleanly.
|
|
1530
|
+
tabsStore.getState().removeTab(tab.tabId);
|
|
1531
|
+
continue;
|
|
1532
|
+
}
|
|
1533
|
+
try {
|
|
1534
|
+
const resolvedId = journeys.start(tab.journeyId, tab.input);
|
|
1535
|
+
if (resolvedId !== tab.instanceId) {
|
|
1536
|
+
tabsStore.getState().updateTab(tab.tabId, { instanceId: resolvedId });
|
|
1537
|
+
}
|
|
1538
|
+
} catch (err) {
|
|
1539
|
+
if (err instanceof UnknownJourneyError) {
|
|
1540
|
+
// Raced with a concurrent unregister; same policy as the pre-check.
|
|
1541
|
+
tabsStore.getState().removeTab(tab.tabId);
|
|
1542
|
+
continue;
|
|
1543
|
+
}
|
|
1544
|
+
// A real bug (corrupted input, throwing onHydrate, invariant violation).
|
|
1545
|
+
// Drop so the shell still boots, but warn loudly and surface it to the user.
|
|
1546
|
+
notifyUser(`We couldn't restore tab "${tab.title}"`, err);
|
|
1547
|
+
tabsStore.getState().removeTab(tab.tabId);
|
|
1548
|
+
}
|
|
1549
|
+
}
|
|
1550
|
+
```
|
|
1551
|
+
|
|
1552
|
+
Two things worth underlining:
|
|
1553
|
+
|
|
1554
|
+
- `runtime.isRegistered(id)` is a cheap pre-filter. It's not sufficient on its own (a tab might rehydrate fine past the id check and still fail on validation or a user `onHydrate` throw), but it keeps the expected-drop path out of the exception channel so real bugs stand out in logs.
|
|
1555
|
+
- **Don't silently drop in production.** The example shells in this repo use `console.warn` only because they're examples. A real shell should surface the drop to the user - an in-app banner ("We couldn't restore N tab(s)"), an affordance to report the offending blob, or a quarantine store so support can replay it. Users lose trust fast when work vanishes without explanation.
|
|
1556
|
+
|
|
1557
|
+
## Rendering - `JourneyOutlet`
|
|
1558
|
+
|
|
1559
|
+
### Props
|
|
1560
|
+
|
|
1561
|
+
```ts
|
|
1562
|
+
interface JourneyOutletProps {
|
|
1563
|
+
/** Runtime override - usually inherited from <JourneyProvider>. */
|
|
1564
|
+
runtime?: JourneyRuntime;
|
|
1565
|
+
/** The instance to render. Required. */
|
|
1566
|
+
instanceId: InstanceId;
|
|
1567
|
+
/** Module descriptors override - usually inherited from the runtime. */
|
|
1568
|
+
modules?: Readonly<Record<string, ModuleDescriptor<any, any, any, any>>>;
|
|
1569
|
+
/** Shown while status === 'loading'. */
|
|
1570
|
+
loadingFallback?: ReactNode;
|
|
1571
|
+
/** Fired once when the instance terminates. */
|
|
1572
|
+
onFinished?: (outcome: TerminalOutcome) => void;
|
|
1573
|
+
/** Error policy for the step's component. Default: 'abort'. */
|
|
1574
|
+
onStepError?: (err: unknown, ctx: { step: JourneyStep }) => "abort" | "retry" | "ignore";
|
|
1575
|
+
/** Global retry cap per instance (retries do NOT reset on step change). Default: 2. */
|
|
1576
|
+
retryLimit?: number;
|
|
1577
|
+
/**
|
|
1578
|
+
* Replaces the default red notice when the current step points at a
|
|
1579
|
+
* `(moduleId, entry)` pair the runtime doesn't resolve to a registered
|
|
1580
|
+
* module+entry. Shells almost always want to brand this.
|
|
1581
|
+
*/
|
|
1582
|
+
notFoundComponent?: ComponentType<JourneyOutletNotFoundProps>;
|
|
1583
|
+
/**
|
|
1584
|
+
* Replaces the default red notice when a step component throws.
|
|
1585
|
+
* Receives the raw error so shells can route it through their own
|
|
1586
|
+
* error-reporting pipeline.
|
|
1587
|
+
*/
|
|
1588
|
+
errorComponent?: ComponentType<JourneyOutletErrorProps>;
|
|
1589
|
+
/**
|
|
1590
|
+
* Speculatively prefetch chunks for entries reachable from the current
|
|
1591
|
+
* step during idle time after mount, so navigating Next finds the
|
|
1592
|
+
* bundle hot.
|
|
1593
|
+
*
|
|
1594
|
+
* "precise" (default, alias `true`) — read declared `targets` from
|
|
1595
|
+
* `defineTransition({ targets, handle })`-annotated handlers on
|
|
1596
|
+
* the current step's transitions. Preload exactly those entries.
|
|
1597
|
+
* Bare handlers contribute nothing.
|
|
1598
|
+
* "aggressive" — preload every entry referenced anywhere in the
|
|
1599
|
+
* journey's `transitions` map. Useful for unmigrated journeys.
|
|
1600
|
+
* false — opt out entirely.
|
|
1601
|
+
*
|
|
1602
|
+
* No effect for eager (`component:`) entries — their import is
|
|
1603
|
+
* already resolved. SSR is a no-op (the preload effect is browser-only).
|
|
1604
|
+
*/
|
|
1605
|
+
preload?: boolean | "precise" | "aggressive";
|
|
1606
|
+
}
|
|
1607
|
+
|
|
1608
|
+
interface JourneyOutletNotFoundProps {
|
|
1609
|
+
readonly moduleId: string;
|
|
1610
|
+
readonly entry: string;
|
|
1611
|
+
}
|
|
1612
|
+
|
|
1613
|
+
interface JourneyOutletErrorProps {
|
|
1614
|
+
readonly moduleId: string;
|
|
1615
|
+
readonly error: unknown;
|
|
1616
|
+
}
|
|
1617
|
+
|
|
1618
|
+
interface TerminalOutcome {
|
|
1619
|
+
status: "completed" | "aborted";
|
|
1620
|
+
payload: unknown; // value passed to complete(…) or abort(…)
|
|
1621
|
+
instanceId: InstanceId;
|
|
1622
|
+
journeyId: string;
|
|
1623
|
+
}
|
|
1624
|
+
```
|
|
1625
|
+
|
|
1626
|
+
### Typical usage
|
|
1627
|
+
|
|
1628
|
+
With a `<JourneyProvider>` mounted above, `instanceId` is the only required prop:
|
|
1629
|
+
|
|
1630
|
+
```tsx
|
|
1631
|
+
<JourneyOutlet
|
|
1632
|
+
instanceId={tab.instanceId}
|
|
1633
|
+
loadingFallback={<LoadingSpinner />}
|
|
1634
|
+
onFinished={(outcome) => {
|
|
1635
|
+
// outcome = { status, payload, instanceId, journeyId }
|
|
1636
|
+
workspace.closeTab(tab.tabId);
|
|
1637
|
+
}}
|
|
1638
|
+
onStepError={(err, { step }) => "abort" | "retry" | "ignore"}
|
|
1639
|
+
retryLimit={2}
|
|
1640
|
+
/>
|
|
1641
|
+
```
|
|
1642
|
+
|
|
1643
|
+
Without the provider (or when you want to point at a different runtime), pass `runtime` and optionally `modules` explicitly - they always win over context:
|
|
1644
|
+
|
|
1645
|
+
```tsx
|
|
1646
|
+
<JourneyOutlet
|
|
1647
|
+
runtime={manifest.journeys}
|
|
1648
|
+
instanceId={tab.instanceId}
|
|
1649
|
+
modules={manifest.moduleDescriptors}
|
|
1650
|
+
// …
|
|
1651
|
+
/>
|
|
1652
|
+
```
|
|
1653
|
+
|
|
1654
|
+
What it does:
|
|
1655
|
+
|
|
1656
|
+
1. Subscribes to the instance via `useSyncExternalStore`.
|
|
1657
|
+
2. Renders `loadingFallback` while the async persistence `load` is in flight.
|
|
1658
|
+
3. Resolves `step.module` + `step.entry` against the module map (prop, or the one the runtime was built with) and renders its component with a freshly bound `{ input, exit, goBack? }`. Lazy entries are wrapped in `React.lazy` + `<Suspense fallback={entry.fallback ?? loadingFallback ?? null}>` automatically — see [Pattern - lazy entry-points](#pattern---lazy-entry-points-code-splitting-per-step).
|
|
1659
|
+
4. Wraps the step in an error boundary and applies `onStepError` policy. Retries count against `retryLimit` globally per instance (the counter does **not** reset when a retry advances the step), so a throwing component can't bypass the cap by bumping the step token. Lazy import failures surface through this same boundary.
|
|
1660
|
+
5. Fires `onFinished` exactly once when the instance terminates; the outcome carries `{ status, payload, instanceId, journeyId }` so analytics can correlate without re-reading props.
|
|
1661
|
+
6. On unmount while still `active` **or** `loading`, abandons the instance via `runtime.end({ reason: 'unmounted' })`. Two defenses keep the instance alive when it should stay: StrictMode's simulated mount/unmount/remount cycle (same component, same `mountedRef`) and back-to-back independent outlets that hand off to each other (checked via `record.listeners.size`).
|
|
1662
|
+
7. After each step mounts, schedules a `requestIdleCallback` (with a `setTimeout(_, 0)` fallback) to call `preload()` on every entry reachable from the current step (per `preload` mode — see the prop docs above). Effect cancels on step change so a fast advance doesn't race with the previous step's preload set.
|
|
1663
|
+
|
|
1664
|
+
### Error policies in depth
|
|
1665
|
+
|
|
1666
|
+
`onStepError` runs on every thrown error during a step's render or effects. Pick a policy per error:
|
|
1667
|
+
|
|
1668
|
+
| Policy | Behavior |
|
|
1669
|
+
| ---------- | --------------------------------------------------------------------------------------------------- |
|
|
1670
|
+
| `'abort'` | Default. The outlet calls `runtime.end(id, { reason: 'component-error', error })`. |
|
|
1671
|
+
| `'retry'` | Re-mount the step with a fresh React key. Counted against `retryLimit` per instance (not per step). |
|
|
1672
|
+
| `'ignore'` | Keep the module error boundary UI in place until the user transitions away via another action. |
|
|
1673
|
+
|
|
1674
|
+
The retry counter is deliberately per-instance: a step that throws, auto-retries, transitions, and the next step also throws cannot bypass the cap by resetting via a step-token bump. When you truly need per-step retries, increment `retryLimit` and live with the larger overall budget, or classify errors in `onStepError` and `'abort'` on everything except a specific retryable pattern.
|
|
1675
|
+
|
|
1676
|
+
### Outlet hosts - rules of thumb
|
|
1677
|
+
|
|
1678
|
+
- Render the outlet wherever the step should live - tab body, modal body, route element, panel, wizard card. It doesn't care.
|
|
1679
|
+
- A tab that represents a journey should be the outlet's **only** long-lived mount. Unmounting = abandon. Don't swap an outlet for a placeholder and expect the journey to survive.
|
|
1680
|
+
- For wizards that live inside a single always-mounted container (no tab changes), you can mount the outlet inside a `<details>` or a collapsed panel and the instance stays alive even when visually hidden.
|
|
1681
|
+
|
|
1682
|
+
## Hosting plain modules - `ModuleTab`
|
|
1683
|
+
|
|
1684
|
+
`<ModuleTab>` is the non-journey counterpart: it renders a single module entry outside a route, and forwards exits to a shell-provided callback (plus the provider-level `onModuleExit`).
|
|
1685
|
+
|
|
1686
|
+
### Props
|
|
1687
|
+
|
|
1688
|
+
```ts
|
|
1689
|
+
interface ModuleTabProps<TInput = unknown> {
|
|
1690
|
+
module: ModuleDescriptor<any, any, any, any>;
|
|
1691
|
+
entry?: string; // defaults to the module's sole entry when unambiguous
|
|
1692
|
+
input?: TInput;
|
|
1693
|
+
tabId?: string; // opaque passthrough for the onExit callback
|
|
1694
|
+
onExit?: (event: {
|
|
1695
|
+
moduleId: string;
|
|
1696
|
+
entry: string;
|
|
1697
|
+
exit: string;
|
|
1698
|
+
output: unknown;
|
|
1699
|
+
tabId?: string;
|
|
1700
|
+
}) => void;
|
|
1701
|
+
}
|
|
1702
|
+
```
|
|
1703
|
+
|
|
1704
|
+
### Behavior
|
|
1705
|
+
|
|
1706
|
+
- If `entry` is omitted and the module has **one** entry, it's used automatically. If it has several, an error notice is rendered asking for the `entry` prop.
|
|
1707
|
+
- `exit(name, output)` calls `onExit` first (for the per-tab override - typically "close this tab"), then the provider-level `onModuleExit` (for analytics / global telemetry).
|
|
1708
|
+
- When a module predates entry points and only declares a legacy `component`, `<ModuleTab>` renders that - entry/exit contracts are strictly opt-in.
|
|
1709
|
+
|
|
1710
|
+
```tsx
|
|
1711
|
+
function TabContent({ tab, moduleDescriptors, workspace }: Props) {
|
|
1712
|
+
if (tab.kind === "module") {
|
|
1713
|
+
return (
|
|
1714
|
+
<ModuleTab
|
|
1715
|
+
module={moduleDescriptors[tab.moduleId]}
|
|
1716
|
+
entry={tab.entry}
|
|
1717
|
+
input={tab.input}
|
|
1718
|
+
tabId={tab.tabId}
|
|
1719
|
+
onExit={(ev) => workspace.closeTab(tab.tabId)}
|
|
1720
|
+
/>
|
|
1721
|
+
);
|
|
1722
|
+
}
|
|
1723
|
+
return (
|
|
1724
|
+
<JourneyOutlet instanceId={tab.instanceId} onFinished={() => workspace.closeTab(tab.tabId)} />
|
|
1725
|
+
);
|
|
1726
|
+
}
|
|
1727
|
+
```
|
|
1728
|
+
|
|
1729
|
+
## Observation hooks
|
|
1730
|
+
|
|
1731
|
+
```ts
|
|
1732
|
+
defineJourney<…>()({
|
|
1733
|
+
onTransition: (ev) => analytics.track('journey.step', ev),
|
|
1734
|
+
onAbandon: ({ step }) => step?.moduleId === 'billing'
|
|
1735
|
+
? { abort: { reason: 'payment-abandoned' } }
|
|
1736
|
+
: { abort: { reason: 'abandoned' } },
|
|
1737
|
+
onComplete: (ctx, result)=> analytics.track('journey.complete', { ctx, result }),
|
|
1738
|
+
onAbort: (ctx, reason)=> analytics.track('journey.abort', { ctx, reason }),
|
|
1739
|
+
onHydrate: (blob) => migrateIfNeeded(blob),
|
|
1740
|
+
});
|
|
1741
|
+
```
|
|
1742
|
+
|
|
1743
|
+
`onAbandon` is the only observation hook that returns a `TransitionResult` - the runtime uses it to decide the terminal state after `runtime.end(id, reason)`. Default: `{ abort: { reason: 'abandoned' } }`. The hook is also allowed to return `{ complete: … }` (rare, usually for "save a partial outcome on shutdown") or `{ next: … }` to reroute into another module entry instead of terminating - `runtime.end(id)` then keeps the journey alive on the rerouted step. Treat the reroute branch as an escape hatch: it can surprise callers that expect `end()` to be, well, an end. Exceptions from any hook are caught and logged; they never block the transition.
|
|
1744
|
+
|
|
1745
|
+
Registration options can supply an extra `onTransition` that fires after the definition's - handy when the shell wants host-level analytics without coupling it into the journey module.
|
|
1746
|
+
|
|
1747
|
+
Every `TransitionEvent` carries a `kind: "step" | "invoke" | "resume"` discriminator plus optional `child` / `outcome` / `resume` fields. Hooks that only care about top-level steps should filter on `kind === "step"`; consumers that want the full picture read the extra fields. See [Telemetry: `TransitionEvent.kind`](#telemetry-transitioneventkind) for the full breakdown. The registration-level `onError` carries a `phase: "step" | "invoke" | "resume" | "abandon"` discriminator on its `ctx` so a control-plane failure (a thrown resume, an unknown invoke handle) is distinguishable from a step-component throw.
|
|
1748
|
+
|
|
1749
|
+
## Testing
|
|
1750
|
+
|
|
1751
|
+
### Module-level - `renderModule({ entry, exit })`
|
|
1752
|
+
|
|
1753
|
+
No journey runtime involved; the `exit` callback is a test spy.
|
|
1754
|
+
|
|
1755
|
+
```ts
|
|
1756
|
+
import { renderModule } from "@react-router-modules/testing"; // or @tanstack-react-modules/testing
|
|
1757
|
+
|
|
1758
|
+
const exit = vi.fn();
|
|
1759
|
+
await renderModule(accountModule, {
|
|
1760
|
+
entry: "review",
|
|
1761
|
+
input: { customerId: "C-1" },
|
|
1762
|
+
exit,
|
|
1763
|
+
deps: {
|
|
1764
|
+
/* … */
|
|
1765
|
+
},
|
|
1766
|
+
});
|
|
1767
|
+
// assert UI, click buttons, assert exit was called with the right (name, output)
|
|
1768
|
+
```
|
|
1769
|
+
|
|
1770
|
+
### Journey-level pure - `simulateJourney`
|
|
1771
|
+
|
|
1772
|
+
Headless. No React. Fires exits against the transition graph and exposes state / step / history / status plus a recorded `transitions` stream for assertions on analytics rules without wiring an `onTransition` by hand.
|
|
1773
|
+
|
|
1774
|
+
```ts
|
|
1775
|
+
import { simulateJourney } from "@modular-react/journeys/testing";
|
|
1776
|
+
|
|
1777
|
+
const sim = simulateJourney(customerOnboardingJourney, { customerId: "C-1" });
|
|
1778
|
+
// `currentStep` is `step` with a non-null assertion baked in: throws if the
|
|
1779
|
+
// journey has terminated, so test assertions on the live path stay terse.
|
|
1780
|
+
// Use `sim.step` (which is `JourneyStep | null`) when a null is expected.
|
|
1781
|
+
expect(sim.currentStep.moduleId).toBe("profile");
|
|
1782
|
+
|
|
1783
|
+
sim.fireExit("profileComplete", {
|
|
1784
|
+
customerId: "C-1",
|
|
1785
|
+
hint: { suggestedTier: "pro", rationale: "12 seats" },
|
|
1786
|
+
});
|
|
1787
|
+
expect(sim.currentStep.moduleId).toBe("plan");
|
|
1788
|
+
expect(sim.state.hint?.suggestedTier).toBe("pro");
|
|
1789
|
+
|
|
1790
|
+
sim.fireExit("choseStandard", { plan: { tier: "pro", monthly: 79 } });
|
|
1791
|
+
expect(sim.currentStep.moduleId).toBe("billing");
|
|
1792
|
+
|
|
1793
|
+
// Initial start + two hops since the simulator started.
|
|
1794
|
+
expect(sim.transitions).toHaveLength(3);
|
|
1795
|
+
expect(sim.transitions.at(-1)!.to?.moduleId).toBe("billing");
|
|
1796
|
+
|
|
1797
|
+
// Once the journey terminates, `sim.terminalPayload` mirrors the value
|
|
1798
|
+
// passed to `complete` / `abort`; `sim.serialize()` returns the exact blob
|
|
1799
|
+
// shape a persistence adapter would see (useful for pinning round-trip
|
|
1800
|
+
// invariants without reaching into runtime internals).
|
|
1801
|
+
sim.fireExit("paid", { reference: "PAY-1", amount: 79 });
|
|
1802
|
+
expect(sim.terminalPayload).toEqual({ kind: "paid", reference: "PAY-1", amount: 79 });
|
|
1803
|
+
expect(sim.serialize().status).toBe("completed");
|
|
1804
|
+
```
|
|
1805
|
+
|
|
1806
|
+
### Integration - `renderJourney`
|
|
1807
|
+
|
|
1808
|
+
Mounts `<JourneyOutlet>` inside a minimal registry.
|
|
1809
|
+
|
|
1810
|
+
```ts
|
|
1811
|
+
import { renderJourney } from "@react-router-modules/testing";
|
|
1812
|
+
|
|
1813
|
+
const { getByText, runtime, instanceId } = renderJourney(customerOnboardingJourney, {
|
|
1814
|
+
modules: [profileModule, planModule, billingModule],
|
|
1815
|
+
input: { customerId: "C-1" },
|
|
1816
|
+
deps: {
|
|
1817
|
+
/* … */
|
|
1818
|
+
},
|
|
1819
|
+
});
|
|
1820
|
+
```
|
|
1821
|
+
|
|
1822
|
+
### Common assertion shapes
|
|
1823
|
+
|
|
1824
|
+
```ts
|
|
1825
|
+
// A journey finishes with a specific payload
|
|
1826
|
+
sim.fireExit("paid", { reference: "PAY-123", amount: 100 });
|
|
1827
|
+
expect(sim.status).toBe("completed");
|
|
1828
|
+
expect(sim.state.outcome).toEqual({ kind: "paid", reference: "PAY-123", amount: 100 });
|
|
1829
|
+
|
|
1830
|
+
// An abandon path
|
|
1831
|
+
sim.end({ reason: "shift-ended" });
|
|
1832
|
+
expect(sim.status).toBe("aborted");
|
|
1833
|
+
|
|
1834
|
+
// goBack restores state (allowBack: 'rollback')
|
|
1835
|
+
sim.fireExit("choseStandard", { plan: PRO });
|
|
1836
|
+
expect(sim.state.selectedPlan).toEqual(PRO);
|
|
1837
|
+
sim.goBack();
|
|
1838
|
+
expect(sim.state.selectedPlan).toBeNull();
|
|
1839
|
+
|
|
1840
|
+
// Analytics ordering
|
|
1841
|
+
expect(sim.transitions.map((t) => t.exit)).toEqual(["profileComplete", "choseStandard", "paid"]);
|
|
1842
|
+
```
|
|
1843
|
+
|
|
1844
|
+
### Testing persistence adapters
|
|
1845
|
+
|
|
1846
|
+
Drive the adapter with a fake storage map:
|
|
1847
|
+
|
|
1848
|
+
```ts
|
|
1849
|
+
const store = new Map<string, string>();
|
|
1850
|
+
const persistence = defineJourneyPersistence<MyInput, MyState>({
|
|
1851
|
+
keyFor: ({ journeyId, input }) => `journey:${input.customerId}:${journeyId}`,
|
|
1852
|
+
load: (k) => {
|
|
1853
|
+
const raw = store.get(k);
|
|
1854
|
+
return raw ? JSON.parse(raw) : null;
|
|
1855
|
+
},
|
|
1856
|
+
save: (k, b) => {
|
|
1857
|
+
store.set(k, JSON.stringify(b));
|
|
1858
|
+
},
|
|
1859
|
+
remove: (k) => {
|
|
1860
|
+
store.delete(k);
|
|
1861
|
+
},
|
|
1862
|
+
});
|
|
1863
|
+
|
|
1864
|
+
// assert `store` entries after key transitions
|
|
1865
|
+
```
|
|
1866
|
+
|
|
1867
|
+
For the full integration - including reload recovery - mount `renderJourney` with the adapter wired into registration, fire exits, then unmount + remount and check the state resumed.
|
|
1868
|
+
|
|
1869
|
+
## Integration patterns
|
|
1870
|
+
|
|
1871
|
+
Journeys are container-agnostic. Four common integration shapes:
|
|
1872
|
+
|
|
1873
|
+
### Pattern - tabbed workspace (recommended)
|
|
1874
|
+
|
|
1875
|
+
Shell maintains a list of open tabs. A tab either renders `<ModuleTab>` (plain module) or `<JourneyOutlet>` (journey). Closing a tab unmounts the outlet → abandons the journey. Completion fires `onFinished` → shell closes the tab.
|
|
1876
|
+
|
|
1877
|
+
```tsx
|
|
1878
|
+
function TabContent({ tab }: { tab: Tab }) {
|
|
1879
|
+
if (tab.kind === "journey") {
|
|
1880
|
+
return (
|
|
1881
|
+
<JourneyOutlet
|
|
1882
|
+
instanceId={tab.instanceId}
|
|
1883
|
+
loadingFallback={<Spinner />}
|
|
1884
|
+
onFinished={() => workspace.closeTab(tab.tabId)}
|
|
1885
|
+
/>
|
|
1886
|
+
);
|
|
1887
|
+
}
|
|
1888
|
+
return (
|
|
1889
|
+
<ModuleTab
|
|
1890
|
+
module={descriptors[tab.moduleId]}
|
|
1891
|
+
entry={tab.entry}
|
|
1892
|
+
input={tab.input}
|
|
1893
|
+
tabId={tab.tabId}
|
|
1894
|
+
onExit={() => workspace.closeTab(tab.tabId)}
|
|
1895
|
+
/>
|
|
1896
|
+
);
|
|
1897
|
+
}
|
|
1898
|
+
```
|
|
1899
|
+
|
|
1900
|
+
See [`examples/react-router/customer-onboarding-journey/`](../../examples/react-router/customer-onboarding-journey/) and [`examples/tanstack-router/customer-onboarding-journey/`](../../examples/tanstack-router/customer-onboarding-journey/) for end-to-end implementations.
|
|
1901
|
+
|
|
1902
|
+
### Pattern - modal-hosted journey
|
|
1903
|
+
|
|
1904
|
+
For a one-shot flow that should block the rest of the UI (KYC top-up, mandatory re-auth):
|
|
1905
|
+
|
|
1906
|
+
```tsx
|
|
1907
|
+
function JourneyModal({ journeyId, input, onClose }: Props) {
|
|
1908
|
+
const runtime = useJourneyContext()!.runtime;
|
|
1909
|
+
const [instanceId] = useState(() => runtime.start(journeyId, input));
|
|
1910
|
+
|
|
1911
|
+
return (
|
|
1912
|
+
<Dialog open onClose={onClose}>
|
|
1913
|
+
<JourneyOutlet instanceId={instanceId} loadingFallback={<Spinner />} onFinished={onClose} />
|
|
1914
|
+
</Dialog>
|
|
1915
|
+
);
|
|
1916
|
+
}
|
|
1917
|
+
```
|
|
1918
|
+
|
|
1919
|
+
Dismissing the dialog unmounts the outlet → `onAbandon` fires. If you'd rather persist the journey across dismissals, keep the outlet mounted inside a hidden element and toggle dialog visibility only.
|
|
1920
|
+
|
|
1921
|
+
### Pattern - full-page route
|
|
1922
|
+
|
|
1923
|
+
Mount `<JourneyOutlet>` as a route element. The journey lives as long as the user stays on that route - a route change unmounts it and abandons the instance. Combine with `runtime.hydrate(blob)` to resume from a URL-bound audit blob.
|
|
1924
|
+
|
|
1925
|
+
```tsx
|
|
1926
|
+
// react-router element:
|
|
1927
|
+
<Route path="/onboarding/:customerId" element={<OnboardingPage />} />;
|
|
1928
|
+
|
|
1929
|
+
// OnboardingPage:
|
|
1930
|
+
function OnboardingPage() {
|
|
1931
|
+
const { customerId } = useParams();
|
|
1932
|
+
const runtime = useJourneyContext()!.runtime;
|
|
1933
|
+
const [instanceId] = useState(() => runtime.start("customer-onboarding", { customerId }));
|
|
1934
|
+
return <JourneyOutlet instanceId={instanceId} onFinished={() => nav("/")} />;
|
|
1935
|
+
}
|
|
1936
|
+
```
|
|
1937
|
+
|
|
1938
|
+
### Pattern - embedded wizard panel
|
|
1939
|
+
|
|
1940
|
+
A journey driven inside an always-mounted panel (e.g. a side drawer). The outlet stays mounted even when the panel is collapsed - the instance survives toggle.
|
|
1941
|
+
|
|
1942
|
+
```tsx
|
|
1943
|
+
<aside style={{ display: collapsed ? "none" : "block" }}>
|
|
1944
|
+
<JourneyOutlet instanceId={instanceId} />
|
|
1945
|
+
</aside>
|
|
1946
|
+
```
|
|
1947
|
+
|
|
1948
|
+
Only unmount the outlet when you truly want to abandon.
|
|
1949
|
+
|
|
1950
|
+
### Pattern - command-palette launcher
|
|
1951
|
+
|
|
1952
|
+
No extra React - the runtime is accessible anywhere via `useJourneyContext()` or a shell-level reference. Command handlers call `runtime.start(…)` and the shell's tab service mounts the outlet:
|
|
1953
|
+
|
|
1954
|
+
```ts
|
|
1955
|
+
palette.register("onboarding:start", async ({ customerId }) => {
|
|
1956
|
+
workspace.openTab({ kind: "journey", id: "customer-onboarding", input: { customerId } });
|
|
1957
|
+
});
|
|
1958
|
+
```
|
|
1959
|
+
|
|
1960
|
+
## Debugging
|
|
1961
|
+
|
|
1962
|
+
The runtime enables dev-mode logs automatically when `process.env.NODE_ENV !== 'production'`. Signals to watch for:
|
|
1963
|
+
|
|
1964
|
+
| Message | Meaning |
|
|
1965
|
+
| -------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
|
1966
|
+
| `Stale exit("X") dropped on instance <id>` | A captured `exit` callback fired after the step advanced. Expected after double-clicks; suspicious if it floods the console. |
|
|
1967
|
+
| `No transition for exit("X") on <module>.<entry>` | The component fired an exit the journey doesn't map. Usually a missing transition handler, or a refactor left a dead exit. |
|
|
1968
|
+
| `Transition handler for <module>.<entry>."X" returned a Promise` | A handler returned a thenable - illegal. The runtime aborts the journey. Move async into a loading entry. |
|
|
1969
|
+
| `Journey "<id>" declares allowBack for <module>.<entry> but the runtime was created without the module…` | `createJourneyRuntime` was called without `modules` wired, so the back button can't be resolved. Use the registry-built runtime. |
|
|
1970
|
+
| `onTransition / onAbandon / onComplete / onAbort threw` | Observation hook exception. Caught; the transition still commits. Fix the hook. |
|
|
1971
|
+
| `persistence.load / save / remove rejected / threw` | Storage error. Transitions continue in memory; the last good blob stays on disk. |
|
|
1972
|
+
| `hydrate after async load failed` | Stored blob could not be hydrated (version mismatch without migrator, rollbackSnapshots length mismatch). The runtime discards the blob and starts fresh. |
|
|
1973
|
+
|
|
1974
|
+
To introspect a running journey by hand:
|
|
1975
|
+
|
|
1976
|
+
```ts
|
|
1977
|
+
const runtime = manifest.journeys;
|
|
1978
|
+
const ids = runtime.listInstances();
|
|
1979
|
+
for (const id of ids) {
|
|
1980
|
+
const inst = runtime.getInstance(id);
|
|
1981
|
+
console.log(id, inst?.status, inst?.step, inst?.history.length);
|
|
1982
|
+
}
|
|
1983
|
+
```
|
|
1984
|
+
|
|
1985
|
+
For a fully-headless trace, drive a scenario through `simulateJourney` and inspect `sim.transitions`.
|
|
1986
|
+
|
|
1987
|
+
## Errors, races, and edge cases
|
|
1988
|
+
|
|
1989
|
+
- **Two exits in rapid succession** - step tokens guarantee the first wins; later calls are dropped.
|
|
1990
|
+
- **Exit fired from an unmounted component** - same mechanism: token mismatch, drop.
|
|
1991
|
+
- **Component throws during render or effect** - wrapped in an error boundary; `onStepError` decides (`'abort' | 'retry' | 'ignore'`). `'retry'` is capped by `retryLimit` (default 2) counted globally per instance; a throwing step that advances into another throwing step cannot reset the budget.
|
|
1992
|
+
- **Async transition handler** - illegal. A handler that returns a `Promise` aborts the journey with `{ reason: 'transition-returned-promise' }` and logs an error in dev. Put async work inside a loading entry point on a module instead.
|
|
1993
|
+
- **User closes the tab mid-journey** - `JourneyOutlet` unmounts → `runtime.end(id, { reason: 'unmounted' })` → `onAbandon` fires → instance becomes `aborted`. If the unmount happens while the instance is still in `loading` (persistence probe hasn't settled), the instance is transitioned straight to `aborted` without firing `onAbandon` - the journey never actually started.
|
|
1994
|
+
- **Same journey, same persistence key, different input** - the persisted blob wins. The new input is discarded. Apps that want new inputs to reset should `runtime.end(oldId)` (and optionally clear the persistence key) first, or include a nonce in the key.
|
|
1995
|
+
- **Terminal or corrupt persisted blob** - `start()` deletes it via `persistence.remove(key)` before minting a fresh instance, so stale blobs don't pile up in storage across reloads.
|
|
1996
|
+
- **Hydrate blob whose `rollbackSnapshots` length disagrees with `history`** - rejected with `JourneyHydrationError`. Use `onHydrate` to migrate or pad the blob.
|
|
1997
|
+
- **Duplicate `instanceId` on hydrate** - `runtime.hydrate()` throws if an instance with that id is already live. Call `forget(id)` first if the replace-in-place is intentional.
|
|
1998
|
+
- **Circular transitions** - allowed; `history` grows. Long-running journeys should use `maxHistory` or be designed to terminate.
|
|
1999
|
+
- **Circular invocations across journeys** - guarded. The four-layer safety net (static cycle check + runtime same-id, depth-cap, and resume-bounce-cap) aborts the offending parent with `invoke-cycle`, `invoke-stack-overflow`, `invoke-undeclared-child`, or `resume-bounce-limit` — see [Cycle and recursion safety](#cycle-and-recursion-safety) for tuning the caps and declaring the call set up-front.
|
|
2000
|
+
- **Deep mutation of journey state corrupts rollback snapshots** - snapshots are **shallow clones**, so a mutation that reaches into nested objects updates the snapshot too. Treat state as immutable; produce new objects rather than mutating in place. In development the runtime shallow-freezes each captured snapshot, so a top-level mutation throws immediately - deep mutations still slip through.
|
|
2001
|
+
- **Runtime input validation is not built in** - `schema<T>()` is type-only and gives you compile-time checking on entry inputs. The runtime does not validate at the boundary. If `start()` / `hydrate()` inputs come from untrusted sources (URL params, server payloads), wire `zod` / `valibot` / your validator of choice in front of them.
|
|
2002
|
+
- **A module bumped past the journey's `moduleCompat` range** - `resolveManifest()` throws `JourneyValidationError` listing every mismatched (journey, module, range, registered version) tuple at once. The deployment refuses to come up; bump the journey's `moduleCompat`, downgrade the module, or fix the journey's transitions to match the module's new contract. See [Pattern - module compatibility (`moduleCompat`)](#pattern---module-compatibility-modulecompat).
|
|
2003
|
+
|
|
2004
|
+
## Limitations
|
|
2005
|
+
|
|
2006
|
+
Things that aren't implemented today but may land later - these are gaps, not architectural choices.
|
|
2007
|
+
|
|
2008
|
+
- **No URL reflection of journey state.** Journeys are route-agnostic by design. Deep-linking into a mid-journey step is currently an app-level concern (read URL → `runtime.hydrate` → mount outlet).
|
|
2009
|
+
|
|
2010
|
+
Cross-references for things that are sometimes mistaken for limitations:
|
|
2011
|
+
|
|
2012
|
+
- _"Transitions can't be async."_ True, by design - see [Transition handlers are pure and synchronous](#transition-handlers-are-pure-and-synchronous).
|
|
2013
|
+
- _"Exits are module-level, not per-entry."_ Same - see [Entry points and exit points on a module](#entry-points-and-exit-points-on-a-module).
|
|
2014
|
+
- _"`history` grows unbounded by default."_ Configurable - see [Pattern - bounded history (`maxHistory`)](#pattern--bounded-history-maxhistory) and the rollback-snapshot caveat there.
|
|
2015
|
+
- _"State mutation can corrupt rollback snapshots."_ Treat state as immutable - see the snapshot bullet in [Errors, races, and edge cases](#errors-races-and-edge-cases).
|
|
2016
|
+
- _"There's no runtime input validation."_ `schema<T>()` is type-only - see the validation bullet in [Errors, races, and edge cases](#errors-races-and-edge-cases).
|
|
2017
|
+
|
|
2018
|
+
## TypeScript inference notes
|
|
2019
|
+
|
|
2020
|
+
The journey type surface is designed so a handful of explicit generics produce end-to-end checking across modules, journey, and persistence. A few things to know:
|
|
2021
|
+
|
|
2022
|
+
### `defineJourney` is curried
|
|
2023
|
+
|
|
2024
|
+
```ts
|
|
2025
|
+
export const journey = defineJourney<MyModules, MyState>()({
|
|
2026
|
+
initialState: (input: { customerId: string }) => ({
|
|
2027
|
+
/* … */
|
|
2028
|
+
}),
|
|
2029
|
+
// ^^^^^ TInput is inferred from `initialState`'s parameter
|
|
2030
|
+
});
|
|
2031
|
+
|
|
2032
|
+
// Add TOutput as the third generic to narrow `complete` payloads (and the
|
|
2033
|
+
// type a parent's resume sees on `outcome.payload` when this journey is
|
|
2034
|
+
// invoked):
|
|
2035
|
+
export const childJourney = defineJourney<ChildModules, ChildState, { token: string }>()({
|
|
2036
|
+
/* …complete: { token: ... } is type-checked against { token: string } */
|
|
2037
|
+
});
|
|
2038
|
+
```
|
|
2039
|
+
|
|
2040
|
+
`TModules` and `TState` are supplied explicitly; the optional third generic `TOutput` narrows the journey's terminal payload (defaults to `unknown`); `TInput` is inferred from `initialState` so you don't repeat the shape. If you ever need to constrain `TInput` explicitly (e.g. for a shared starter-input type), annotate the `initialState` parameter.
|
|
2041
|
+
|
|
2042
|
+
### The module type map is per-journey, not global
|
|
2043
|
+
|
|
2044
|
+
```ts
|
|
2045
|
+
type OnboardingModules = {
|
|
2046
|
+
readonly profile: typeof profileModule;
|
|
2047
|
+
readonly plan: typeof planModule;
|
|
2048
|
+
readonly billing: typeof billingModule;
|
|
2049
|
+
};
|
|
2050
|
+
```
|
|
2051
|
+
|
|
2052
|
+
All imports are `import type` - modules are **not** pulled into the journey's bundle. Don't hoist a shared `AppModules` across every journey in the app: unrelated journeys pay each other's type-check cost and churn together on unrelated changes.
|
|
2053
|
+
|
|
2054
|
+
### `StepSpec` is a discriminated union
|
|
2055
|
+
|
|
2056
|
+
`StepSpec<TModules>` expands to `{ module: 'profile'; entry: 'review'; input: {…} } | { module: 'plan'; entry: 'choose'; input: {…} } | …`. Every transition result that returns `{ next: … }` narrows the `input` type against the target entry. You cannot type-check your way into passing a wrong-shaped input - but only if the modules in the type map expose narrow `entryPoints` / `exitPoints` literals (i.e. the module descriptor was typed via `const` + `as const` or via `defineModule` called without shell-level generics - the canonical authoring pattern in [Authoring patterns](#authoring-patterns)).
|
|
2057
|
+
|
|
2058
|
+
### `schema<T>()` is a type brand, not a validator
|
|
2059
|
+
|
|
2060
|
+
```ts
|
|
2061
|
+
input: schema<{ customerId: string }>();
|
|
2062
|
+
```
|
|
2063
|
+
|
|
2064
|
+
Returns an empty object whose type carries `T`. Zero runtime cost and zero validation. For runtime validation, wire zod/valibot inside the component (or at the `workspace.openTab` boundary) until `validateInput` lands in core.
|
|
2065
|
+
|
|
2066
|
+
### `defineJourneyPersistence<TInput, TState>` ties the adapter to the journey
|
|
2067
|
+
|
|
2068
|
+
```ts
|
|
2069
|
+
const persistence = defineJourneyPersistence<OnboardingInput, OnboardingState>({
|
|
2070
|
+
keyFor: ({ input }) => `journey:${input.customerId}:onboarding`, // input typed as OnboardingInput
|
|
2071
|
+
save: (k, b) => api.save(k, b), // b typed as SerializedJourney<OnboardingState>
|
|
2072
|
+
load: (k) => api.load(k),
|
|
2073
|
+
remove: (k) => api.remove(k),
|
|
2074
|
+
});
|
|
2075
|
+
```
|
|
2076
|
+
|
|
2077
|
+
Without the helper, `input` on `keyFor` is `unknown`; with it, every callback is end-to-end typed.
|
|
2078
|
+
|
|
2079
|
+
## API reference
|
|
2080
|
+
|
|
2081
|
+
Every export you're likely to call, grouped by role.
|
|
2082
|
+
|
|
2083
|
+
### From `@modular-react/core` (module authors)
|
|
2084
|
+
|
|
2085
|
+
| Export | Signature | Purpose |
|
|
2086
|
+
| ------------------------------------------------ | ---------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
|
2087
|
+
| `defineEntry` | overloaded — `<T>(e: EagerModuleEntryPoint<T> \| LazyModuleEntryPoint<T>) => same` | Identity helper. Two forms: eager (`{ component, input?, allowBack? }`) or lazy (`{ lazy: () => import(…), fallback?, input?, allowBack? }`). Mutually exclusive at the type level. |
|
|
2088
|
+
| `defineExit` | `<T = void>() => ExitPointSchema<T>` | Identity helper for an exit-point literal. Zero runtime cost. |
|
|
2089
|
+
| `schema` | `<T>() => InputSchema<T>` | Type-only brand used to carry an input/output shape. Zero runtime cost. |
|
|
2090
|
+
| `ModuleEntryProps` | `<TInput, TExits extends ExitPointMap = {}>` | Typed props for an entry component: `{ input, exit, goBack? }`. |
|
|
2091
|
+
| `ModuleEntryPoint` | `EagerModuleEntryPoint<T> \| LazyModuleEntryPoint<T>` | Discriminated union — eager (`component`) or lazy (`lazy`). |
|
|
2092
|
+
| `EagerModuleEntryPoint` / `LazyModuleEntryPoint` | `{ component, input?, allowBack?, lazy?: never }` / `{ lazy, fallback?, input?, allowBack?, component?: never }` | The two branches of the union, exported for callers that want to type a single variant explicitly. |
|
|
2093
|
+
| `LazyEntryComponent` | `() => Promise<{ default: ComponentType<…> } \| ComponentType<…>>` | Importer signature accepted by `defineEntry({ lazy })`. Both default-export and direct-export module shapes are normalized at runtime. |
|
|
2094
|
+
| `ExitPointSchema` | `{ output? }` | Exit-point descriptor shape. |
|
|
2095
|
+
| `ExitFn` | `<TExits>(name, output?) => void` | The function signature `exit` gets on an entry component. |
|
|
2096
|
+
| `EntryPointMap` / `ExitPointMap` | `Record<string, ModuleEntryPoint<any>>` / `Record<string, ExitPointSchema<any>>` | Map shapes on `ModuleDescriptor`. |
|
|
2097
|
+
|
|
2098
|
+
### Authoring (`@modular-react/journeys`)
|
|
2099
|
+
|
|
2100
|
+
| Export | Signature | Purpose |
|
|
2101
|
+
| ----------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
|
2102
|
+
| `defineJourney` | `<TModules, TState, TOutput?>() => <TInput>(def: JourneyDefinition<TModules, TState, TInput, TOutput>) => def` | Identity helper with full inference on transitions and state. Curried so `TInput` infers from `initialState`. The optional third generic `TOutput` narrows `complete` payloads (and a parent's resume `outcome.payload` when invoked). |
|
|
2103
|
+
| `defineJourneyHandle` | `<TModules, TState, TInput, TOutput>(def) => JourneyHandle<string, TInput, TOutput>` | Builds a typed token from a journey definition so modules and shells can call `runtime.start(handle, input)` without importing the journey's runtime code. Carries `TOutput` so a parent's resume sees `outcome.payload` typed end-to-end. |
|
|
2104
|
+
| `invoke` | `<TInput, TOutput>({ handle, input, resume }) => { invoke: InvokeSpec<TInput, TOutput> }` | Typed builder for the `{ invoke }` arm of `TransitionResult`. Cross-checks `input` against the handle's `TInput` — a bare object literal won't. See [Composing journeys](#composing-journeys-invoke--resume). |
|
|
2105
|
+
| `validateJourneyGraph` | `(journeys: readonly RegisteredJourney[]) => void` | Static cycle check over the directed graph derived from each journey's `invokes` field. Run automatically by `validateJourneyContracts`; exported separately for shells that compose registrations across plugin boundaries. See [Cycle and recursion safety](#cycle-and-recursion-safety). |
|
|
2106
|
+
| `isJourneySystemAbort` | `(payload: unknown) => payload is JourneySystemAbortReason` | Type guard that narrows an `unknown` abort payload to the runtime's discriminated `JourneySystemAbortReason` union. Returns `false` for author-supplied aborts so a `{ abort: { reason: "user-cancelled" } }` does not collide with the system codes. See [Cycle and recursion safety - Failure surface](#cycle-and-recursion-safety). |
|
|
2107
|
+
| `selectModule` | `<TModules>() => <TKey>(key, cases) => StepSpec<TModules>` | Exhaustive state-driven dispatch helper for transition handlers - see [the pattern](#pattern---exhaustive-state-driven-module-dispatch-selectmodule). Missing branches are a compile error. |
|
|
2108
|
+
| `selectModuleOrDefault` | `<TModules>() => <TKey>(key, cases, fallback) => StepSpec<TModules>` | Sibling of `selectModule` accepting a partial cases map plus an explicit fallback `StepSpec` - see [the pattern](#pattern---fallback-dispatch-selectmoduleordefault). Use when most discriminator values funnel through a generic module. |
|
|
2109
|
+
| `defineTransition` | curried: `<TModules, TState?, TOutput?>() => (spec) => handle & { targets }`<br>bare: `<THandler, TTargets>(spec) => handle & { targets }` | Wraps a transition handler with declared `targets` — a mixed array of `{ module, entry }` step refs and `"complete"` / `"abort"` / `"invoke"` sentinels. Required on every wrapped handler; narrows the handler's return to the declared arms (returning an undeclared arm is a compile error). The outlet's default `preload="precise"` mode reads the step refs to warm chunks during idle time; sentinels are skipped. See [Pattern - declared targets](#pattern---declared-targets-with-definetransition-auto-preload--narrowed-return-type). |
|
|
2110
|
+
| `isTerminalSentinel` | `(value: unknown) => value is "complete" \| "abort" \| "invoke"` | Type guard for the terminal sentinels accepted in `targets`. Exposed for hosts that introspect a wrapped handler's targets and want to separate step refs from terminal arms. |
|
|
2111
|
+
| `isAnnotatedTransition` | `(value: unknown) => boolean` | Type guard for `defineTransition`-wrapped handlers. The outlet's preloader uses it; exported for custom hosts that walk a journey's `transitions` map. |
|
|
2112
|
+
| `defineJourneyPersistence` | `<TInput, TState>(adapter) => JourneyPersistence<TState, TInput>` | Types `keyFor`'s `input` against `TInput`, `load`/`save` against `TState`. |
|
|
2113
|
+
| `createWebStoragePersistence` | `<TInput, TState>({ keyFor, storage? }) => JourneyPersistence<TState, TInput>` | Stock `localStorage` / `sessionStorage` adapter. SSR-safe, auto-clears corrupt JSON entries. Pass `storage` to override the backing store. |
|
|
2114
|
+
| `createMemoryPersistence` | `<TInput, TState>({ keyFor, initial?, clone? }) => MemoryPersistence<TInput, TState>` | `Map`-backed adapter for tests/SSR. Exposes `size()` / `entries()` / `clear()`. Deep-clones on `save` and `load` by default. |
|
|
2115
|
+
|
|
2116
|
+
### Rendering + context (`@modular-react/journeys`)
|
|
2117
|
+
|
|
2118
|
+
| Export | Purpose |
|
|
2119
|
+
| --------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
|
2120
|
+
| `JourneyProvider` | Context provider for the runtime and optional `onModuleExit`. Mount once at the shell root. |
|
|
2121
|
+
| `useJourneyContext` | Reads the current provider value, or `null`. |
|
|
2122
|
+
| `JourneyOutlet` | Renders the current step of a journey instance. Handles loading, error boundary, terminal, and abandon-on-unmount. By default walks the active call chain and renders the leaf — pass `leafOnly={false}` for layered presentations. |
|
|
2123
|
+
| `useJourneyCallStack` | `(runtime, rootId) => readonly InstanceId[]` — returns the live root → … → leaf chain. Subscribes to every link so the array re-resolves when the chain shifts. |
|
|
2124
|
+
| `ModuleTab` | Renders a single module entry outside a route. Non-journey counterpart to `JourneyOutlet`. |
|
|
2125
|
+
|
|
2126
|
+
### Runtime + validation (`@modular-react/journeys`)
|
|
2127
|
+
|
|
2128
|
+
| Export | Purpose |
|
|
2129
|
+
| --------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
|
2130
|
+
| `createJourneyRuntime` | Low-level runtime factory. Normally called by the registry; exported for advanced use (test harnesses, custom hosts). |
|
|
2131
|
+
| `validateJourneyContracts` | Cross-checks a journey's transitions and `moduleCompat` against registered modules. Runs automatically at `resolveManifest()` / `resolve()`; exported for custom validation flows. |
|
|
2132
|
+
| `validateJourneyDefinition` | Structural sanity check on a definition's own shape. Runs automatically in `registerJourney`. |
|
|
2133
|
+
| `satisfies(version, range)` | Test a `MAJOR.MINOR.PATCH` version against the npm-semver subset used by `moduleCompat`. Exported for apps that want to run the same compatibility math outside the journeys validator (e.g. checking a saved blob's recorded version against a current journey's compat range). See [Pattern - module compatibility (`moduleCompat`)](#pattern---module-compatibility-modulecompat) for the supported syntax. |
|
|
2134
|
+
| `compareVersions(a, b)` | Order two version strings lexicographically by `(major, minor, patch)`. Returns `-1` / `0` / `1`. Useful for "is this saved blob older than the cutoff?" comparisons; for matching against a range use `satisfies`. |
|
|
2135
|
+
| `SemverParseError` | Thrown by `satisfies` / `compareVersions` on malformed input. |
|
|
2136
|
+
| `JourneyValidationError` | Aggregated validation error. `.issues: readonly string[]`. |
|
|
2137
|
+
| `JourneyHydrationError` | Thrown from `hydrate` / async-load when the blob is unusable. |
|
|
2138
|
+
| `UnknownJourneyError` | Thrown from `runtime.start(journeyId, input)` when `journeyId` is not registered. Catch this specifically in shell-state rehydration loops (see [Rehydrating shell-level work](#rehydrating-shell-level-work-tabs-task-queues-drafts)); surface anything else as a real bug. |
|
|
2139
|
+
|
|
2140
|
+
### Runtime methods (the `JourneyRuntime` returned as `manifest.journeys`)
|
|
2141
|
+
|
|
2142
|
+
| Method | Description |
|
|
2143
|
+
| --------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
|
2144
|
+
| `start(handle, input)` | **Preferred.** Start or resume an instance via a handle (`defineJourneyHandle`); `input` is type-checked end-to-end. Idempotent per persistence key. |
|
|
2145
|
+
| `start(journeyId, input)` | String-id form for dynamic dispatch (e.g. navbar `{ kind: "journey-start", journeyId }`). Accepts any `input`. |
|
|
2146
|
+
| `hydrate(journeyId, blob)` | Explicit read-only hydrate. Persistence-unlinked. Returns `InstanceId`. |
|
|
2147
|
+
| `getInstance(id)` | Current snapshot of an instance, or `null`. Stable-identity between changes (for `useSyncExternalStore`). |
|
|
2148
|
+
| `listInstances()` / `listDefinitions()` | Enumerate. Useful for admin tooling. |
|
|
2149
|
+
| `isRegistered(journeyId)` | Cheap "is this id known?" predicate. Use to filter persisted shell state before calling `start()` - keeps the expected-drop path out of the exception channel. |
|
|
2150
|
+
| `subscribe(id, listener)` | Subscribe to change notifications for one instance. Returns unsubscribe. |
|
|
2151
|
+
| `end(id, reason?)` | Force-terminate. Fires `onAbandon` if active; treats `loading` as a direct abort without firing `onAbandon`. |
|
|
2152
|
+
| `forget(id)` / `forgetTerminal()` | Drop terminal instances from memory. `forget` is a no-op on active/loading; `forgetTerminal` batches them all. |
|
|
2153
|
+
|
|
2154
|
+
### Registration options (passed to `registry.registerJourney`)
|
|
2155
|
+
|
|
2156
|
+
```ts
|
|
2157
|
+
interface JourneyRegisterOptions<TState = unknown, TInput = unknown> {
|
|
2158
|
+
/**
|
|
2159
|
+
* Fires after every transition, in addition to the definition's
|
|
2160
|
+
* `onTransition`. Useful for shell telemetry that doesn't belong in
|
|
2161
|
+
* journey authoring code.
|
|
2162
|
+
*/
|
|
2163
|
+
onTransition?: (ev: TransitionEvent) => void;
|
|
2164
|
+
/**
|
|
2165
|
+
* Fires when the journey reaches `{ complete }`. Runs after the
|
|
2166
|
+
* definition-level `onComplete` (both fire). Shell-level completion
|
|
2167
|
+
* analytics belong here.
|
|
2168
|
+
*/
|
|
2169
|
+
onComplete?: (ctx: TerminalCtx<TState>, result: unknown) => void;
|
|
2170
|
+
/**
|
|
2171
|
+
* Fires on abort (via `{ abort }` transition, a thrown handler, or
|
|
2172
|
+
* `runtime.end(id)`). Runs after the definition-level `onAbort`.
|
|
2173
|
+
*/
|
|
2174
|
+
onAbort?: (ctx: TerminalCtx<TState>, reason: unknown) => void;
|
|
2175
|
+
/**
|
|
2176
|
+
* Overrides the definition's `onAbandon` when `runtime.end(id)` is
|
|
2177
|
+
* called on an active instance. Use to swap abandon behaviour for a
|
|
2178
|
+
* specific deployment (e.g. "save as completed on end-of-shift" vs
|
|
2179
|
+
* the journey author's default "abort").
|
|
2180
|
+
*/
|
|
2181
|
+
onAbandon?: (ctx: AbandonCtx) => TransitionResult;
|
|
2182
|
+
/**
|
|
2183
|
+
* Layered on top of the definition-level `onHydrate` - runs **after**
|
|
2184
|
+
* the definition transforms the blob. Useful for shell-level migrations
|
|
2185
|
+
* the journey author doesn't know about (redacting env-specific ids on
|
|
2186
|
+
* load, etc.).
|
|
2187
|
+
*/
|
|
2188
|
+
onHydrate?: (blob: SerializedJourney<TState>) => SerializedJourney<TState>;
|
|
2189
|
+
/**
|
|
2190
|
+
* Observation-only error hook. Fires whenever a step component throws,
|
|
2191
|
+
* a transition handler throws, an invoke fails validation (unknown
|
|
2192
|
+
* journey id, unknown resume name, `runtime.start` itself threw), a
|
|
2193
|
+
* resume handler throws, or a custom `onAbandon` crashes. The runtime
|
|
2194
|
+
* still aborts / retries according to the outlet's `onStepError`
|
|
2195
|
+
* policy - use this for telemetry, not control flow.
|
|
2196
|
+
*
|
|
2197
|
+
* The `phase` discriminator lets telemetry distinguish a step-render
|
|
2198
|
+
* throw from a control-plane failure: `"step"` for component throws,
|
|
2199
|
+
* `"invoke"` for invoke validation / start failures, `"resume"` for
|
|
2200
|
+
* resume-handler throws and resume-name lookup failures at child
|
|
2201
|
+
* terminal time, `"abandon"` for a custom `onAbandon` crash.
|
|
2202
|
+
*/
|
|
2203
|
+
onError?: (
|
|
2204
|
+
err: unknown,
|
|
2205
|
+
ctx: {
|
|
2206
|
+
step: JourneyStep | null;
|
|
2207
|
+
phase: "step" | "invoke" | "resume" | "abandon";
|
|
2208
|
+
},
|
|
2209
|
+
) => void;
|
|
2210
|
+
/**
|
|
2211
|
+
* Optional. Without it, journeys live in memory only - every
|
|
2212
|
+
* `runtime.start()` mints a fresh instance and nothing is written to
|
|
2213
|
+
* storage.
|
|
2214
|
+
*/
|
|
2215
|
+
persistence?: JourneyPersistence<TState>;
|
|
2216
|
+
/**
|
|
2217
|
+
* Maximum `history` entries retained (oldest dropped). See the caveat
|
|
2218
|
+
* with `allowBack` below.
|
|
2219
|
+
*/
|
|
2220
|
+
maxHistory?: number;
|
|
2221
|
+
/**
|
|
2222
|
+
* Optional nav contribution. When set, the journeys plugin emits a
|
|
2223
|
+
* navigation item for this journey so pure launchers don't need a
|
|
2224
|
+
* shadow module to host them. The contributed item carries
|
|
2225
|
+
* `action: { kind: "journey-start", journeyId, buildInput }`; the
|
|
2226
|
+
* shell's navbar dispatcher starts the journey on click.
|
|
2227
|
+
*/
|
|
2228
|
+
nav?: JourneyNavContribution<TInput>;
|
|
2229
|
+
/**
|
|
2230
|
+
* Cap on the depth of an in-flight invoke chain that includes this
|
|
2231
|
+
* journey. Resolved as the **minimum** non-undefined `maxCallStackDepth`
|
|
2232
|
+
* across the active chain (ancestors + parent + child). Default: `16`.
|
|
2233
|
+
* See [Cycle and recursion safety](#cycle-and-recursion-safety).
|
|
2234
|
+
*/
|
|
2235
|
+
maxCallStackDepth?: number;
|
|
2236
|
+
/**
|
|
2237
|
+
* Cap on consecutive resume bounces at the same parent step (a "bounce"
|
|
2238
|
+
* is a resume returning `{ invoke }` instead of advancing). Default:
|
|
2239
|
+
* `8`. Per-parent only — children don't influence their parent's budget.
|
|
2240
|
+
*/
|
|
2241
|
+
maxResumeBouncesPerStep?: number;
|
|
2242
|
+
}
|
|
2243
|
+
|
|
2244
|
+
interface JourneyNavContribution<TInput = unknown> {
|
|
2245
|
+
label: string;
|
|
2246
|
+
icon?: string | React.ComponentType<{ className?: string }>;
|
|
2247
|
+
group?: string;
|
|
2248
|
+
order?: number;
|
|
2249
|
+
hidden?: boolean;
|
|
2250
|
+
meta?: unknown;
|
|
2251
|
+
/** Builds the journey's `input` at click time. Typed against `TInput`. */
|
|
2252
|
+
buildInput?: (ctx?: unknown) => TInput;
|
|
2253
|
+
}
|
|
2254
|
+
```
|
|
2255
|
+
|
|
2256
|
+
**Journey-contributed nav.** Set `options.nav` on `registerJourney` when the journey is reachable from a top-level navbar entry without a dedicated launcher module. The journeys plugin collects every `nav` block at manifest time and merges them into `manifest.navigation` alongside module-contributed items. Items the plugin emits carry an `action: { kind: "journey-start", journeyId, buildInput }` - the framework stays agnostic about how the shell dispatches the action; the shell's navbar renderer switches on `action` to start the journey via `runtime.start(journeyId, buildInput?.())`.
|
|
2257
|
+
|
|
2258
|
+
Apps with a narrowed `TNavItem` (typed i18n labels, typed action union, typed meta bag) should supply a `buildNavItem` adapter on `journeysPlugin({ buildNavItem })` to reshape the plugin's default item into the app-narrowed type:
|
|
2259
|
+
|
|
2260
|
+
```ts
|
|
2261
|
+
journeysPlugin<AppNavItem>({
|
|
2262
|
+
buildNavItem: (defaults, raw) => ({
|
|
2263
|
+
...defaults,
|
|
2264
|
+
meta: { analytics: `launch-${raw.journeyId}` },
|
|
2265
|
+
}),
|
|
2266
|
+
});
|
|
2267
|
+
```
|
|
2268
|
+
|
|
2269
|
+
See the React Router example shell (`examples/react-router/customer-onboarding-journey/shell/`) for an end-to-end wiring: the `quick-bill` journey surfaces itself as the navbar "Start a quick bill" button; the shell's `TopNav` component renders items based on whether they carry an `action` or a plain `to`.
|
|
2270
|
+
|
|
2271
|
+
### Serialized shape (persistence)
|
|
2272
|
+
|
|
2273
|
+
```ts
|
|
2274
|
+
interface SerializedJourney<TState> {
|
|
2275
|
+
definitionId: string;
|
|
2276
|
+
version: string;
|
|
2277
|
+
instanceId: string;
|
|
2278
|
+
status: "active" | "completed" | "aborted";
|
|
2279
|
+
step: { moduleId: string; entry: string; input: unknown } | null;
|
|
2280
|
+
history: ReadonlyArray<{ moduleId: string; entry: string; input: unknown }>;
|
|
2281
|
+
/** Index-aligned with `history`; `null` for entries without a rollback snapshot. */
|
|
2282
|
+
rollbackSnapshots?: ReadonlyArray<TState | null>;
|
|
2283
|
+
/** Present only on terminal blobs - mirrors the transition's `complete`/`abort` payload. */
|
|
2284
|
+
terminalPayload?: unknown;
|
|
2285
|
+
state: TState;
|
|
2286
|
+
startedAt: string;
|
|
2287
|
+
updatedAt: string;
|
|
2288
|
+
/**
|
|
2289
|
+
* Set when this instance has invoked a child journey that hasn't yet
|
|
2290
|
+
* resumed. `childPersistenceKey` is `null` when the child journey has
|
|
2291
|
+
* no persistence configured. Cleared on resume / cascade-end.
|
|
2292
|
+
*/
|
|
2293
|
+
pendingInvoke?: {
|
|
2294
|
+
childJourneyId: string;
|
|
2295
|
+
childInstanceId: string;
|
|
2296
|
+
childPersistenceKey: string | null;
|
|
2297
|
+
resumeName: string;
|
|
2298
|
+
};
|
|
2299
|
+
/**
|
|
2300
|
+
* Set on a child instance whose parent invoked it. Mirrors the
|
|
2301
|
+
* parent's `pendingInvoke` so a child blob loaded out-of-order on
|
|
2302
|
+
* reload still knows which parent to resume.
|
|
2303
|
+
*/
|
|
2304
|
+
parentLink?: {
|
|
2305
|
+
parentInstanceId: string;
|
|
2306
|
+
resumeName: string;
|
|
2307
|
+
};
|
|
2308
|
+
}
|
|
2309
|
+
```
|
|
2310
|
+
|
|
2311
|
+
### Testing (`@modular-react/journeys/testing`)
|
|
2312
|
+
|
|
2313
|
+
| Export | Purpose |
|
|
2314
|
+
| -------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
|
2315
|
+
| `simulateJourney` | Headless simulator: fires exits / goBack, exposes `step` / `currentStep` (throws if terminal) / `state` / `history` / `status` / `transitions` / `terminalPayload` / `serialize()`, no React. |
|
|
2316
|
+
| `JourneySimulator` | Type for the object returned by `simulateJourney`. |
|
|
2317
|
+
| `createTestHarness` | Wraps a live `JourneyRuntime` so tests can fire exits, call `goBack`, and inspect instance internals without mounting `<JourneyOutlet>`. Replaces reaching for `getInternals` directly. |
|
|
2318
|
+
| `JourneyTestHarness` | Type returned by `createTestHarness`. |
|
|
2319
|
+
|
|
2320
|
+
### From the router runtime packages
|
|
2321
|
+
|
|
2322
|
+
| Export | Purpose |
|
|
2323
|
+
| ----------------------------------------- | ----------------------------------------------------------------------------------------------------------- |
|
|
2324
|
+
| `registry.registerJourney(def, options?)` | Adds a journey to the registry. Structural check runs immediately; contract check at resolve time. |
|
|
2325
|
+
| `manifest.journeys` | The `JourneyRuntime` bound to the resolved registry. Always non-null (no-op when no journey is registered). |
|
|
2326
|
+
| `manifest.moduleDescriptors` | Map of module id → descriptor. Consumed by `<ModuleTab>` and by any code rendering a module entry directly. |
|
|
2327
|
+
| `ResolveManifestOptions.onModuleExit` | Shell-level fallback for module exits fired through `<ModuleTab>`. Wire to analytics or global tab-close. |
|
|
2328
|
+
|
|
2329
|
+
## Example projects
|
|
2330
|
+
|
|
2331
|
+
Complete, runnable walk-throughs live under `examples/`:
|
|
2332
|
+
|
|
2333
|
+
- [`examples/react-router/customer-onboarding-journey/`](../../examples/react-router/customer-onboarding-journey/) - React Router integration.
|
|
2334
|
+
- [`examples/tanstack-router/customer-onboarding-journey/`](../../examples/tanstack-router/customer-onboarding-journey/) - TanStack Router integration with the same modules and journey.
|
|
2335
|
+
|
|
2336
|
+
Each `customer-onboarding-journey` example demonstrates:
|
|
2337
|
+
|
|
2338
|
+
- `defineEntry` / `defineExit` across three modules (profile, plan, billing).
|
|
2339
|
+
- `defineJourney` composing them with typed transitions and a shared state.
|
|
2340
|
+
- `registry.registerJourney(...)` with a localStorage persistence adapter - reload the page mid-flow and the tab resumes at the last step.
|
|
2341
|
+
- A minimal tabbed shell mounting `<JourneyOutlet>` and `<ModuleTab>` side-by-side.
|
|
2342
|
+
- `WorkspaceActions.openTab({ kind: 'journey', … })` as the shell-facing API, with `openModuleTab` kept as a `@deprecated` shim.
|
|
2343
|
+
|
|
2344
|
+
The `integration-setup-journey` examples demonstrate the [`selectModule` / `selectModuleOrDefault`](#pattern--exhaustive-state-driven-module-dispatch-selectmodule) dispatch helpers paired with slot-driven discovery:
|
|
2345
|
+
|
|
2346
|
+
- [`examples/react-router/integration-setup-journey/`](../../examples/react-router/integration-setup-journey/) - React Router integration with Playwright coverage of every dispatch branch.
|
|
2347
|
+
- [`examples/tanstack-router/integration-setup-journey/`](../../examples/tanstack-router/integration-setup-journey/) - TanStack Router mirror.
|
|
2348
|
+
|
|
2349
|
+
What they show:
|
|
2350
|
+
|
|
2351
|
+
- A generic `integration-picker` module that reads `useSlots<AppSlots>().integrations` and renders a row per contributing module - the picker stays agnostic of which integrations exist.
|
|
2352
|
+
- Modules contribute themselves to the `integrations` slot at registration time. Two of them (`github`, `strapi`) own dedicated configure components; two (`contentful`, `notion`) are headless `defineSlots` modules with no UI.
|
|
2353
|
+
- The journey's `chosen` transition uses `selectModuleOrDefault` to route github + strapi to their dedicated steps and funnel everything else through `generic-integration`.
|
|
2354
|
+
- An inline note on when to swap to `selectModule` (exhaustive) instead - useful if every kind earns its own dedicated module.
|
|
2355
|
+
|
|
2356
|
+
The `journey-invoke` examples demonstrate [Composing journeys (invoke / resume)](#composing-journeys-invoke--resume) — a parent (`checkout`) suspends mid-flow to run a child (`verify-identity`), picks up the child's typed terminal payload, and continues:
|
|
2357
|
+
|
|
2358
|
+
- [`examples/react-router/journey-invoke/`](../../examples/react-router/journey-invoke/) - React Router integration.
|
|
2359
|
+
- [`examples/tanstack-router/journey-invoke/`](../../examples/tanstack-router/journey-invoke/) - TanStack Router mirror.
|
|
2360
|
+
|
|
2361
|
+
What they show:
|
|
2362
|
+
|
|
2363
|
+
- The `invoke()` helper threading the child handle's `TInput` / `TOutput` through to the parent's transition — wrong-shaped `input` is a compile error.
|
|
2364
|
+
- The sibling `resumes` map keyed identically to `transitions`, with a `ChildOutcome<TOutput>` discriminated union narrowing `outcome.payload` for the completed branch.
|
|
2365
|
+
- Both parent and child registered with persistence adapters — reload mid-verification and the runtime auto-rehydrates the call chain via `pendingInvoke.childPersistenceKey` and relinks parent ↔ child.
|
|
2366
|
+
- A `<JourneyOutlet>` rendering the leaf of the active call chain (the default), plus `useJourneyCallStack` driving a small "call stack" banner.
|
|
2367
|
+
- An `onTransition` hook that filters on `kind` to log invoke / resume hops distinctly from ordinary step transitions.
|