@modular-react/journeys 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md ADDED
@@ -0,0 +1,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
26
+ - [Journey definition patterns](#journey-definition-patterns) — branching, terminals, state rewrites, bounded history
27
+ - [Runtime surface](#runtime-surface) — the `JourneyRuntime` you get back from `manifest.journeys`
28
+ - [Journey handles](#journey-handles) — typed tokens for `runtime.start(handle, input)`
29
+ - [`JourneyProvider` + context](#journeyprovider--context)
30
+ - [Persistence](#persistence) — adapters, key design, save queue, hydrate vs start, versioning
31
+ - [Rendering — `JourneyOutlet`](#rendering--journeyoutlet) — props, error policies, host rules
32
+ - [Hosting plain modules — `ModuleTab`](#hosting-plain-modules--moduletab)
33
+ - [Observation hooks](#observation-hooks)
34
+ - [Testing](#testing) — module-level, pure simulator, integration, persistence adapters
35
+ - [Integration patterns](#integration-patterns) — tabs, modals, routes, wizards, command palette
36
+ - [Debugging](#debugging) — dev-mode warnings and introspection
37
+ - [Errors, races, and edge cases](#errors-races-and-edge-cases)
38
+ - [Limitations](#limitations)
39
+ - [TypeScript inference notes](#typescript-inference-notes)
40
+ - [API reference](#api-reference)
41
+ - [Example projects](#example-projects)
42
+
43
+ ## Installation
44
+
45
+ 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):
46
+
47
+ ```bash
48
+ pnpm add @modular-react/journeys
49
+ ```
50
+
51
+ Peer deps: `@modular-react/core`, `@modular-react/react`, `react`, `react-dom`.
52
+
53
+ 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.
54
+
55
+ ## Mental model
56
+
57
+ Three roles, strictly separated:
58
+
59
+ | Role | Owns | Does NOT know about |
60
+ | ----------- | ----------------------------------------------------------------------------------------------------------- | ---------------------------------------------------- |
61
+ | **Module** | Its entry components, input types, exit names, exit output types. | Journeys. Who opens it. What comes next. |
62
+ | **Journey** | The modules it composes (by type), transitions between entry/exit pairs, shared state. | Shell. Tabs. Routes. |
63
+ | **Shell** | Registering modules + journeys, mounting `<JourneyOutlet>` inside its container (tab, route, modal, panel). | Any specific journey's logic, state, or transitions. |
64
+
65
+ ## Quickstart shortcut: scaffold the journey package
66
+
67
+ If you used the modular-react CLI to bootstrap your project, you can skip writing the journey package boilerplate by hand. Run:
68
+
69
+ ```bash
70
+ # React Router
71
+ npx @react-router-modules/cli create journey customer-onboarding \
72
+ --modules profile,plan,billing --persistence
73
+
74
+ # TanStack Router
75
+ npx @tanstack-react-modules/cli create journey customer-onboarding \
76
+ --modules profile,plan,billing --persistence
77
+ ```
78
+
79
+ 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.
80
+
81
+ If you're not using the CLI (or you want to understand the moving parts before reaching for it), the manual walkthrough follows.
82
+
83
+ ## Quickstart
84
+
85
+ ### 1. Declare a module's entry and exit vocabulary
86
+
87
+ Modules import only from `@modular-react/core`:
88
+
89
+ ```ts
90
+ // modules/profile/src/exits.ts
91
+ import { defineExit } from "@modular-react/core";
92
+ import type { PlanHint } from "./types.js";
93
+
94
+ export const profileExits = {
95
+ profileComplete: defineExit<{ customerId: string; hint: PlanHint }>(),
96
+ readyToBuy: defineExit<{ customerId: string; amount: number }>(),
97
+ needsMoreDetails: defineExit<{ customerId: string; missing: string }>(),
98
+ cancelled: defineExit(),
99
+ } as const;
100
+ export type ProfileExits = typeof profileExits;
101
+ ```
102
+
103
+ ```tsx
104
+ // modules/profile/src/ReviewProfile.tsx
105
+ import type { ModuleEntryProps } from "@modular-react/core";
106
+ import type { ProfileExits } from "./exits.js";
107
+
108
+ export function ReviewProfile({
109
+ input,
110
+ exit,
111
+ }: ModuleEntryProps<{ customerId: string }, ProfileExits>) {
112
+ const customer = useCustomer(input.customerId);
113
+ const hint = suggestPlan(customer);
114
+
115
+ if (customer.readiness === "needs-details") {
116
+ return (
117
+ <button
118
+ onClick={() =>
119
+ exit("needsMoreDetails", {
120
+ customerId: input.customerId,
121
+ missing: customer.readinessDetail,
122
+ })
123
+ }
124
+ >
125
+ Flag for back-office
126
+ </button>
127
+ );
128
+ }
129
+ return (
130
+ <>
131
+ <ProfileSummary customer={customer} hint={hint} />
132
+ <button onClick={() => exit("profileComplete", { customerId: input.customerId, hint })}>
133
+ Pick a plan
134
+ </button>
135
+ {customer.readiness === "self-serve" && (
136
+ <button
137
+ onClick={() =>
138
+ exit("readyToBuy", {
139
+ customerId: input.customerId,
140
+ amount: selfServeAmount(customer),
141
+ })
142
+ }
143
+ >
144
+ Skip ahead — charge now
145
+ </button>
146
+ )}
147
+ <button onClick={() => exit("cancelled")}>Cancel</button>
148
+ </>
149
+ );
150
+ }
151
+ ```
152
+
153
+ ```ts
154
+ // modules/profile/src/index.ts
155
+ import { defineModule, defineEntry, schema } from "@modular-react/core";
156
+ import { profileExits } from "./exits.js";
157
+ import { ReviewProfile } from "./ReviewProfile.js";
158
+
159
+ export default defineModule({
160
+ id: "profile",
161
+ version: "1.0.0",
162
+ exitPoints: profileExits,
163
+ entryPoints: {
164
+ review: defineEntry({
165
+ component: ReviewProfile,
166
+ input: schema<{ customerId: string }>(),
167
+ }),
168
+ },
169
+ });
170
+ ```
171
+
172
+ 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.
173
+
174
+ ### 2. Declare the journey
175
+
176
+ ```ts
177
+ // journeys/customer-onboarding/src/journey.ts
178
+ import { defineJourney } from "@modular-react/journeys";
179
+ import type profileModule from "@myorg/module-profile";
180
+ import type planModule from "@myorg/module-plan";
181
+ import type billingModule from "@myorg/module-billing";
182
+
183
+ type Modules = {
184
+ readonly profile: typeof profileModule;
185
+ readonly plan: typeof planModule;
186
+ readonly billing: typeof billingModule;
187
+ };
188
+
189
+ interface OnboardingState {
190
+ customerId: string;
191
+ hint: PlanHint | null;
192
+ selectedPlan: SubscriptionPlan | null;
193
+ }
194
+
195
+ export const customerOnboardingJourney = defineJourney<Modules, OnboardingState>()({
196
+ id: "customer-onboarding",
197
+ version: "1.0.0",
198
+ initialState: ({ customerId }: { customerId: string }) => ({
199
+ customerId,
200
+ hint: null,
201
+ selectedPlan: null,
202
+ }),
203
+ start: (s) => ({ module: "profile", entry: "review", input: { customerId: s.customerId } }),
204
+ transitions: {
205
+ profile: {
206
+ review: {
207
+ profileComplete: ({ output, state }) => ({
208
+ state: { ...state, hint: output.hint },
209
+ next: {
210
+ module: "plan",
211
+ entry: "choose",
212
+ input: { customerId: state.customerId, hint: output.hint },
213
+ },
214
+ }),
215
+ readyToBuy: ({ output }) => ({
216
+ next: {
217
+ module: "billing",
218
+ entry: "collect",
219
+ input: { customerId: output.customerId, amount: output.amount },
220
+ },
221
+ }),
222
+ needsMoreDetails: ({ output }) => ({
223
+ abort: { reason: "profile-incomplete", missing: output.missing },
224
+ }),
225
+ cancelled: () => ({ abort: { reason: "rep-cancelled" } }),
226
+ },
227
+ },
228
+ // …transitions for `plan` and `billing`…
229
+ },
230
+ });
231
+ ```
232
+
233
+ Module imports are `import type` — the journey never pulls a module into its bundle. Runtime resolution happens by id against the registry.
234
+
235
+ ### 3. Register the journey in the shell
236
+
237
+ Attach the journeys plugin to enable `registry.registerJourney`. Without `.use(journeysPlugin())` the method isn't on the base registry:
238
+
239
+ ```ts
240
+ import { createRegistry } from "@react-router-modules/runtime"; // or @tanstack-react-modules/runtime
241
+ import { journeysPlugin } from "@modular-react/journeys";
242
+ import { customerOnboardingJourney } from "@myorg/journey-customer-onboarding";
243
+
244
+ const registry = createRegistry<AppDeps, AppSlots>({ stores, services }).use(
245
+ // Call once per registry — the plugin closes over its own registration
246
+ // list. The optional `onModuleExit` is the shell-wide dispatcher for
247
+ // module exits fired outside a journey step (see "`JourneyProvider` +
248
+ // context" below).
249
+ journeysPlugin({
250
+ onModuleExit: (ev) => workspace.closeTab(ev.tabId),
251
+ }),
252
+ );
253
+
254
+ registry.register(profileModule);
255
+ registry.register(planModule);
256
+ registry.register(billingModule);
257
+
258
+ // All registration options shown below are optional — a bare
259
+ // `registry.registerJourney(customerOnboardingJourney)` is valid and
260
+ // gives you an in-memory journey with no reload recovery.
261
+ registry.registerJourney(customerOnboardingJourney, {
262
+ persistence: defineJourneyPersistence<OnboardingInput, OnboardingState>({
263
+ keyFor: ({ input }) => `journey:${input.customerId}:customer-onboarding`,
264
+ load: (k) => backend.loadJourney(k),
265
+ save: (k, b) => backend.saveJourney(k, b),
266
+ remove: (k) => backend.deleteJourney(k),
267
+ }),
268
+ // Cap `history` growth for long-running journeys. See the caveat in
269
+ // [Bounded history (`maxHistory`)](#pattern--bounded-history-maxhistory).
270
+ // maxHistory: 50,
271
+ });
272
+
273
+ export const manifest = registry.resolveManifest();
274
+ ```
275
+
276
+ `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.
277
+
278
+ `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.
279
+
280
+ ### 4. Render the journey in a tab (or any container)
281
+
282
+ 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:
283
+
284
+ ```tsx
285
+ import { JourneyOutlet, ModuleTab } from "@modular-react/journeys";
286
+
287
+ function TabContent({ tab, manifest }: { tab: Tab; manifest: ResolvedManifest }) {
288
+ if (tab.kind === "module") {
289
+ return (
290
+ <ModuleTab
291
+ module={manifest.moduleDescriptors[tab.moduleId]}
292
+ entry={tab.entry}
293
+ input={tab.input}
294
+ tabId={tab.tabId}
295
+ // The plugin's `onModuleExit` fires automatically for every module
296
+ // tab; pass `onExit` only for a per-tab override (typically "close
297
+ // this tab").
298
+ onExit={(ev) => workspace.closeTab(tab.tabId)}
299
+ />
300
+ );
301
+ }
302
+ return (
303
+ <JourneyOutlet
304
+ instanceId={tab.instanceId}
305
+ loadingFallback={<LoadingSpinner />}
306
+ onFinished={(outcome) => workspace.closeTab(tab.tabId)}
307
+ />
308
+ );
309
+ }
310
+ ```
311
+
312
+ 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.
313
+
314
+ `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.
315
+
316
+ ### 5. Open the journey
317
+
318
+ Export a **handle** alongside the journey definition so callers can open it with a typed `input` without importing the journey's runtime code:
319
+
320
+ ```ts
321
+ // journeys/customer-onboarding/src/index.ts
322
+ import { defineJourneyHandle } from "@modular-react/journeys";
323
+ export const customerOnboardingHandle = defineJourneyHandle(customerOnboardingJourney);
324
+ ```
325
+
326
+ 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:
327
+
328
+ ```ts
329
+ // In the shell, with `manifest.journeys` in scope:
330
+ const instanceId = manifest.journeys.start(customerOnboardingHandle, { customerId });
331
+ workspace.addJourneyTab({
332
+ instanceId,
333
+ journeyId: customerOnboardingHandle.id,
334
+ input: { customerId },
335
+ title: `Onboarding — ${customerName}`,
336
+ });
337
+ ```
338
+
339
+ 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.
340
+
341
+ ## Core concepts
342
+
343
+ ### Entry points and exit points on a module
344
+
345
+ Two additive (optional) fields on `ModuleDescriptor`:
346
+
347
+ | Field | Shape | Purpose |
348
+ | ------------- | ----------------------------------------------- | ----------------------------------------------------------- |
349
+ | `entryPoints` | `{ [name]: { component, input?, allowBack? } }` | Typed ways to open the module. A module can expose several. |
350
+ | `exitPoints` | `{ [name]: { output? } }` | The module's full outcome vocabulary. |
351
+
352
+ `ModuleEntryProps<TInput, TExits>` typed props for the component — `{ input, exit, goBack? }`, with `exit(name, output)` cross-checked against `TExits` at compile time.
353
+
354
+ 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.
355
+
356
+ ### `allowBack` — three values
357
+
358
+ Declared per entry on the module, opted-in per transition on the journey. Both must agree for `goBack` to appear.
359
+
360
+ | Value | What happens on goBack |
361
+ | ------------------ | ------------------------------------------------------------------------------------------------------------------------------------- |
362
+ | `'preserve-state'` | History pops; journey state is untouched. |
363
+ | `'rollback'` | History pops AND journey state reverts to the snapshot taken before this step was entered (shallow clone — treat state as immutable). |
364
+ | `false` / absent | `goBack` is `undefined` in the component's props. Don't render the back button. |
365
+
366
+ The journey's transition map matches with `allowBack: true` on the exit block:
367
+
368
+ ```ts
369
+ transitions: {
370
+ plan: {
371
+ choose: {
372
+ allowBack: true,
373
+ choseStandard: …,
374
+ },
375
+ },
376
+ }
377
+ ```
378
+
379
+ A `resolveManifest()` error surfaces if the two sides disagree.
380
+
381
+ ### Transition handlers are pure and synchronous
382
+
383
+ - No `await`.
384
+ - No React hooks.
385
+ - No store/service access.
386
+ - No side effects.
387
+
388
+ 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.
389
+
390
+ ### Journey lifecycle
391
+
392
+ ```text
393
+ user triggers exit('X', output)
394
+ → runtime checks step token matches (stale callbacks are dropped)
395
+ → runs transition handler (pure)
396
+ → commits state + step + history atomically
397
+ → fires onTransition (definition first, then registration option)
398
+ → if terminal: fires onComplete / onAbort
399
+ → schedules persistence.save (serialized per instance)
400
+ → JourneyOutlet re-renders with new step or terminal state
401
+ ```
402
+
403
+ 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.
404
+
405
+ ### Instance statuses
406
+
407
+ `JourneyInstance.status` runs through four values:
408
+
409
+ | Status | When | `step` | `<JourneyOutlet>` renders |
410
+ | ------------- | ----------------------------------------------------------------------------------------------- | ------------------------ | ---------------------------------- |
411
+ | `'loading'` | Async `persistence.load()` is in flight (first paint after `start()`). | `null` | `loadingFallback` |
412
+ | `'active'` | The normal running state — `step` points at the module/entry currently on screen. | `{ moduleId, entry, … }` | The step component |
413
+ | `'completed'` | Terminal. A transition returned `{ complete }`. | `null` | `null` (after firing `onFinished`) |
414
+ | `'aborted'` | Terminal. A transition returned `{ abort }`, the outlet unmounted, or `runtime.end` was called. | `null` | `null` (after firing `onFinished`) |
415
+
416
+ Terminal instances stay in memory (so late subscribers can read `terminalPayload`) until you call `runtime.forget(id)` / `runtime.forgetTerminal()`.
417
+
418
+ ### Keys, idempotency, and "resume vs new"
419
+
420
+ 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.
421
+
422
+ Without persistence, every `start()` mints a fresh instance. Two calls = two independent journeys that happen to share a journey id.
423
+
424
+ ## Authoring patterns
425
+
426
+ Patterns below are small, composable recipes — most real apps use two or three of them together.
427
+
428
+ ### Pattern — an exits const shared between the component and the descriptor
429
+
430
+ 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.
431
+
432
+ ```ts
433
+ // modules/profile/src/exits.ts
434
+ export const profileExits = {
435
+ profileComplete: defineExit<{ customerId: string; hint: PlanHint }>(),
436
+ cancelled: defineExit(),
437
+ } as const;
438
+ export type ProfileExits = typeof profileExits;
439
+ ```
440
+
441
+ ```tsx
442
+ // modules/profile/src/ReviewProfile.tsx
443
+ export function ReviewProfile({
444
+ input,
445
+ exit,
446
+ }: ModuleEntryProps<{ customerId: string }, ProfileExits>) {
447
+ /* exit('profileComplete', { customerId: input.customerId, hint }) is type-checked */
448
+ }
449
+ ```
450
+
451
+ ```ts
452
+ // modules/profile/src/index.ts
453
+ export default defineModule({
454
+ id: "profile",
455
+ version: "1.0.0",
456
+ exitPoints: profileExits,
457
+ entryPoints: {
458
+ review: defineEntry({ component: ReviewProfile, input: schema<{ customerId: string }>() }),
459
+ },
460
+ });
461
+ ```
462
+
463
+ 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.
464
+
465
+ ### Pattern — a module exposing several entries
466
+
467
+ ```ts
468
+ export default defineModule({
469
+ id: "billing",
470
+ version: "1.0.0",
471
+ exitPoints: billingExits,
472
+ entryPoints: {
473
+ collect: defineEntry({ component: CollectPayment, input: schema<CollectInput>() }),
474
+ startTrial: defineEntry({ component: StartTrial, input: schema<TrialInput>() }),
475
+ },
476
+ });
477
+ ```
478
+
479
+ The journey's transition map targets `{ module: 'billing', entry: 'collect' }` or `'startTrial'` — the discriminated `StepSpec` enforces that `input` matches the chosen entry.
480
+
481
+ ### Pattern — a loading entry point for async work
482
+
483
+ 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.
484
+
485
+ ```tsx
486
+ // modules/risk/src/LoadRiskReport.tsx
487
+ export function LoadRiskReport({
488
+ input,
489
+ exit,
490
+ }: ModuleEntryProps<{ customerId: string }, RiskExits>) {
491
+ useEffect(() => {
492
+ let cancelled = false;
493
+ (async () => {
494
+ try {
495
+ const report = await api.fetchRiskReport(input.customerId);
496
+ if (!cancelled) exit("reportReady", { report });
497
+ } catch (err) {
498
+ if (!cancelled) exit("failed", { reason: String(err) });
499
+ }
500
+ })();
501
+ return () => {
502
+ cancelled = true;
503
+ };
504
+ }, [input.customerId]);
505
+
506
+ return <LoadingSpinner label="Computing risk…" />;
507
+ }
508
+ ```
509
+
510
+ ```ts
511
+ // journey
512
+ transitions: {
513
+ account: {
514
+ review: {
515
+ needsRiskCheck: ({ output }) => ({
516
+ next: { module: "risk", entry: "load", input: { customerId: output.customerId } },
517
+ }),
518
+ },
519
+ },
520
+ risk: {
521
+ load: {
522
+ reportReady: ({ output, state }) => ({
523
+ state: { ...state, risk: output.report },
524
+ next: { module: "decisions", entry: "choose", input: { risk: output.report } },
525
+ }),
526
+ failed: ({ output }) => ({ abort: { reason: "risk-check-failed", detail: output.reason } }),
527
+ },
528
+ },
529
+ }
530
+ ```
531
+
532
+ 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.
533
+
534
+ ### Pattern — optional exits (entries that don't emit every exit)
535
+
536
+ 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.
537
+
538
+ ### Pattern — `allowBack` on an entry, `allowBack: true` on the transition
539
+
540
+ For `goBack` to appear in the component's props, **both sides** must opt in:
541
+
542
+ ```ts
543
+ // module
544
+ entryPoints: {
545
+ choose: defineEntry({ component: ChoosePlan, input: schema<ChooseInput>(), allowBack: "preserve-state" }),
546
+ }
547
+
548
+ // journey
549
+ transitions: {
550
+ plan: {
551
+ choose: {
552
+ allowBack: true, // journey-side opt-in
553
+ // …exit handlers…
554
+ },
555
+ },
556
+ }
557
+ ```
558
+
559
+ 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.
560
+
561
+ ## Journey definition patterns
562
+
563
+ ### Pattern — branching on exit name
564
+
565
+ 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:
566
+
567
+ ```ts
568
+ profile: {
569
+ review: {
570
+ profileComplete: ({ output, state }) => ({
571
+ state: { ...state, hint: output.hint },
572
+ next: { module: "plan", entry: "choose", input: { customerId: state.customerId, hint: output.hint } },
573
+ }),
574
+ readyToBuy: ({ output }) => ({
575
+ next: { module: "billing", entry: "collect", input: { customerId: output.customerId, amount: output.amount } },
576
+ }),
577
+ },
578
+ },
579
+ ```
580
+
581
+ ### Pattern — branching on state/output inside a handler
582
+
583
+ Handlers are plain functions — branch with `if` / `switch` on output or state. Return whichever `TransitionResult` makes sense.
584
+
585
+ ```ts
586
+ review: {
587
+ done: ({ output, state }) =>
588
+ output.needsKyc
589
+ ? { next: { module: "kyc", entry: "collect", input: { customerId: state.customerId } } }
590
+ : { complete: { reason: "ok" } },
591
+ }
592
+ ```
593
+
594
+ ### Pattern — terminal with structured payload
595
+
596
+ `complete` and `abort` both take `unknown` — pass any shape you want. Consumers read it via `instance.terminalPayload` or the `outcome.payload` arg to `onFinished`.
597
+
598
+ ```ts
599
+ paid: ({ output }) => ({ complete: { kind: "paid", reference: output.reference, amount: output.amount } }),
600
+ ```
601
+
602
+ ### Pattern — overriding `state` during a transition
603
+
604
+ Every handler is free to rewrite state:
605
+
606
+ ```ts
607
+ choseStandard: ({ output, state }) => ({
608
+ state: { ...state, selectedPlan: output.plan },
609
+ next: { module: "billing", entry: "collect", input: { customerId: state.customerId, amount: output.plan.monthly } },
610
+ }),
611
+ ```
612
+
613
+ 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.
614
+
615
+ ### Pattern — keeping state immutable
616
+
617
+ 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.
618
+
619
+ ### Pattern — bounded history (`maxHistory`)
620
+
621
+ Register with a cap to prevent unbounded growth in long-running journeys:
622
+
623
+ ```ts
624
+ registry.registerJourney(journey, { maxHistory: 50 });
625
+ ```
626
+
627
+ 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.
628
+
629
+ Omitting `maxHistory`, or passing `0` or a negative number, leaves history unbounded.
630
+
631
+ ## Runtime surface
632
+
633
+ `manifest.journeys` implements `JourneyRuntime`:
634
+
635
+ ```ts
636
+ interface JourneyRuntime {
637
+ /**
638
+ * Handle form (preferred) — `input` is type-checked against the handle's
639
+ * phantom `TInput`. See "Journey handles" below for the pattern.
640
+ */
641
+ start<TId extends string, TInput>(
642
+ handle: JourneyHandleRef<TId, TInput>,
643
+ input: TInput,
644
+ ): InstanceId;
645
+ /**
646
+ * String-id form — accepts any `input`. Useful for dynamic dispatch
647
+ * where the id only exists at runtime (e.g. a navbar action carrying
648
+ * `{ kind: "journey-start", journeyId }`).
649
+ */
650
+ start<TInput>(journeyId: string, input: TInput): InstanceId;
651
+
652
+ /**
653
+ * Explicit hydrate from a caller-supplied blob. Persistence-unlinked:
654
+ * the hydrated instance doesn't claim a persistence key and won't be
655
+ * saved back. Useful for read-only audit/replay views.
656
+ */
657
+ hydrate<TState>(journeyId: string, blob: SerializedJourney<TState>): InstanceId;
658
+
659
+ getInstance(id: InstanceId): JourneyInstance | null;
660
+ listInstances(): readonly InstanceId[];
661
+ listDefinitions(): readonly JourneyDefinitionSummary[];
662
+
663
+ /**
664
+ * Cheap predicate for "is this journey id known to this runtime?"
665
+ * Useful when rehydrating persisted shell state (tabs, task queue, …)
666
+ * to drop entries for journeys renamed or removed between deploys —
667
+ * avoids routing expected drops through the `UnknownJourneyError`
668
+ * exception channel.
669
+ */
670
+ isRegistered(journeyId: string): boolean;
671
+
672
+ /** Subscribe to changes on one instance. Returns unsubscribe. */
673
+ subscribe(id: InstanceId, listener: () => void): () => void;
674
+
675
+ /**
676
+ * Force-terminate an instance. Fires `onAbandon` if still active;
677
+ * no-op if already terminal or unknown.
678
+ */
679
+ end(id: InstanceId, reason?: unknown): void;
680
+
681
+ /** Drop a terminal instance from memory. No-op on active/loading. */
682
+ forget(id: InstanceId): void;
683
+
684
+ /** Drop every terminal instance in one call. Returns the drop count. */
685
+ forgetTerminal(): number;
686
+ }
687
+ ```
688
+
689
+ 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.
690
+
691
+ ### When to call which
692
+
693
+ | Situation | Use |
694
+ | --------------------------------------------------------------------- | ---------------------------------------------------------------- |
695
+ | User clicks "start customer onboarding". | `runtime.start(onboardingHandle, { customerId })` — handle form. |
696
+ | Dynamic dispatch (navbar action / command palette with an opaque id). | `runtime.start(action.journeyId, input)` — string-id form. |
697
+ | Reloading the shell and restoring tabs from localStorage. | `runtime.start(…)` again — persistence resumes. |
698
+ | Filter persisted shell state before calling `start()`. | `runtime.isRegistered(journeyId)` — cheap pre-check. |
699
+ | Read-only "show me what this journey looked like in audit log #1234". | `runtime.hydrate(journeyId, blob)` — no persistence. |
700
+ | Shell wants to react to state changes (tab title, breadcrumb). | `runtime.subscribe(id, listener)` |
701
+ | User closes a journey tab before it completes. | Let `<JourneyOutlet>` unmount — it calls `end()`. |
702
+ | Shell explicitly cancels (e.g. "end shift"). | `runtime.end(id, { reason: 'end-of-shift' })` |
703
+ | Long-running workspace accumulated finished journeys; free memory. | `runtime.forgetTerminal()` |
704
+ | After `onFinished`, prune this specific terminal instance. | `runtime.forget(id)` |
705
+
706
+ ### `listDefinitions()` and `listInstances()`
707
+
708
+ 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)`.
709
+
710
+ ### Journey handles
711
+
712
+ 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:
713
+
714
+ ```ts
715
+ // journeys/customer-onboarding/src/customer-onboarding.ts
716
+ import { defineJourney, defineJourneyHandle } from "@modular-react/journeys";
717
+
718
+ export const customerOnboardingJourney = defineJourney<Modules, State>()({
719
+ id: "customer-onboarding",
720
+ version: "1.0.0",
721
+ initialState: ({ customerId }: { customerId: string }) => ({
722
+ /* … */
723
+ }),
724
+ /* … */
725
+ });
726
+
727
+ // Publish a handle alongside the journey definition — same package, same file.
728
+ export const customerOnboardingHandle = defineJourneyHandle(customerOnboardingJourney);
729
+ ```
730
+
731
+ At the call site, pass the handle to `runtime.start`:
732
+
733
+ ```ts
734
+ import { customerOnboardingHandle } from "@myorg/journey-customer-onboarding";
735
+
736
+ const instanceId = runtime.start(customerOnboardingHandle, { customerId: "C-1" });
737
+ // input is type-checked end-to-end — wrong shape = compile error.
738
+ ```
739
+
740
+ `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.
741
+
742
+ Why handles exist:
743
+
744
+ - **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.
745
+ - **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`).
746
+ - **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.
747
+
748
+ 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.
749
+
750
+ ## `JourneyProvider` + context
751
+
752
+ `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:
753
+
754
+ ```tsx
755
+ <JourneyProvider runtime={manifest.journeys} onModuleExit={manifest.onModuleExit}>
756
+ <AppRoutes />
757
+ </JourneyProvider>
758
+ ```
759
+
760
+ 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.
761
+
762
+ 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.
763
+
764
+ ## Persistence
765
+
766
+ **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`).
767
+
768
+ 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:
769
+
770
+ ```ts
771
+ import { defineJourneyPersistence } from "@modular-react/journeys";
772
+
773
+ registry.registerJourney(journey, {
774
+ persistence: defineJourneyPersistence<OnboardingInput, OnboardingState>({
775
+ keyFor: ({ journeyId, input }) => `journey:${input.customerId}:${journeyId}`,
776
+ load: (key) => backend.loadJourney(key),
777
+ save: (key, blob) => backend.saveJourney(key, blob),
778
+ remove: (key) => backend.deleteJourney(key),
779
+ }),
780
+ });
781
+ ```
782
+
783
+ A plain object matching `JourneyPersistence<TState>` still works if you'd rather not use the helper.
784
+
785
+ ### Stock adapters: `createWebStoragePersistence` and `createMemoryPersistence`
786
+
787
+ 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 })`.
788
+
789
+ **`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):
790
+
791
+ ```ts
792
+ import { createWebStoragePersistence } from "@modular-react/journeys";
793
+
794
+ // Defaults to localStorage (or no-ops under SSR).
795
+ export const journeyPersistence = createWebStoragePersistence<OnboardingInput, OnboardingState>({
796
+ keyFor: ({ journeyId, input }) => `journey:${input.customerId}:${journeyId}`,
797
+ });
798
+
799
+ registry.registerJourney(customerOnboardingJourney, { persistence: journeyPersistence });
800
+ ```
801
+
802
+ 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`.
803
+
804
+ You can swap the backing store by passing a `storage` option:
805
+
806
+ ```ts
807
+ // Tab-scoped — state dies with the tab.
808
+ createWebStoragePersistence<MyInput, MyState>({
809
+ keyFor: ({ journeyId, input }) => `s:${input.id}:${journeyId}`,
810
+ storage: typeof sessionStorage !== "undefined" ? sessionStorage : null,
811
+ });
812
+
813
+ // Lazy getter — re-evaluated per call. Useful when storage availability can
814
+ // flip after hydration (feature-detect then flip a flag).
815
+ createWebStoragePersistence<MyInput, MyState>({
816
+ keyFor: ({ journeyId, input }) => `j:${input.id}:${journeyId}`,
817
+ storage: () => (canUseStorage() ? localStorage : null),
818
+ });
819
+ ```
820
+
821
+ 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.
822
+
823
+ **`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.
824
+
825
+ ```ts
826
+ import { createMemoryPersistence } from "@modular-react/journeys";
827
+
828
+ const store = createMemoryPersistence<OnboardingInput, OnboardingState>({
829
+ keyFor: ({ journeyId, input }) => `${journeyId}:${input.customerId}`,
830
+ });
831
+
832
+ // Pre-seed for a resume test — the runtime finds the blob on first start():
833
+ const seeded = createMemoryPersistence<OnboardingInput, OnboardingState>({
834
+ keyFor: ({ journeyId, input }) => `${journeyId}:${input.customerId}`,
835
+ initial: [["onboarding:C-1", existingBlob]],
836
+ });
837
+
838
+ // Test-only helpers (not on JourneyPersistence) — useful for assertions:
839
+ expect(store.size()).toBe(1);
840
+ expect(store.entries()).toMatchObject([...]);
841
+ store.clear();
842
+ ```
843
+
844
+ 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.
845
+
846
+ 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.
847
+
848
+ Guarantees:
849
+
850
+ - **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.
851
+ - **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.
852
+ - **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`.
853
+ - **`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.
854
+ - **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.
855
+
856
+ ### Key design — picking the right `keyFor`
857
+
858
+ `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.
859
+
860
+ Common shapes:
861
+
862
+ | Scope | `keyFor` | Effect |
863
+ | ---------------------------------- | ------------------------------------------------------------------ | ------------------------------------------------------------------------------------------------ |
864
+ | One journey per customer | `` `journey:${input.customerId}:${journeyId}` `` | Reload resumes. Opening the same customer's journey twice = same tab/instance. |
865
+ | 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. |
866
+ | One journey per (customer, matter) | `` `journey:${input.customerId}:${input.matterId}:${journeyId}` `` | Supports concurrent journeys for the same customer on distinct matters. |
867
+ | 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". |
868
+
869
+ `keyFor` deliberately does **not** receive `instanceId` — probing happens before one exists, and mixing the two forms has historically produced subtle key mismatches.
870
+
871
+ 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.
872
+
873
+ ### Sync vs async `load`
874
+
875
+ `load()` may return `SerializedJourney | null` synchronously **or** a `Promise<SerializedJourney | null>`. The runtime accommodates both:
876
+
877
+ - **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.
878
+ - **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.
879
+
880
+ ### Save queue
881
+
882
+ `save()` is serialized **per instance**:
883
+
884
+ - At most one `save()` in flight per instance.
885
+ - Concurrent state changes during a save coalesce into a single "next save" slot (later changes overwrite earlier pending saves).
886
+ - `remove()` on a terminal transition cancels any queued save.
887
+ - Errors are caught and logged; the instance stays in memory and continues accepting transitions.
888
+
889
+ This guarantees your backend never sees out-of-order writes for one journey, even with rapid clicks and slow IO.
890
+
891
+ ### Explicit `runtime.hydrate` vs `runtime.start`
892
+
893
+ They serve different purposes:
894
+
895
+ | You want to… | Call |
896
+ | ------------------------------------------------------------------ | --------------------------------------------------------------- |
897
+ | Open / resume a live journey for a user, with persistence wiring. | `runtime.start(id, input)` |
898
+ | Render a **read-only** audit/replay view of a stored blob. | `runtime.hydrate(id, blob)` |
899
+ | Inspect a completed journey from an audit log without resuming it. | `runtime.hydrate(id, blob)` — terminal blobs are accepted here. |
900
+
901
+ `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.
902
+
903
+ `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.
904
+
905
+ ### Versioning
906
+
907
+ Every serialized blob carries the journey's `version`. On hydrate:
908
+
909
+ - **Default (strict):** throw `JourneyHydrationError` if `blob.version !== definition.version`. The error message names "version mismatch".
910
+ - **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.
911
+
912
+ `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.
913
+
914
+ Always supply `onHydrate` in production apps that ship new journey versions over time. A minimal pattern:
915
+
916
+ ```ts
917
+ onHydrate: (blob) => {
918
+ switch (blob.version) {
919
+ case "1.0.0":
920
+ return blob;
921
+ case "0.9.0":
922
+ return migrateFrom09(blob);
923
+ default:
924
+ throw new JourneyHydrationError(`unknown version ${blob.version}`);
925
+ }
926
+ };
927
+ ```
928
+
929
+ 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.
930
+
931
+ ### Rehydrating shell-level work (tabs, task queues, drafts)
932
+
933
+ 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:
934
+
935
+ ```ts
936
+ import { UnknownJourneyError } from "@modular-react/journeys";
937
+
938
+ for (const tab of persistedTabs) {
939
+ if (!journeys.isRegistered(tab.journeyId)) {
940
+ // The journey was renamed or removed between deploys. Expected after
941
+ // version skew; drop the tab cleanly.
942
+ tabsStore.getState().removeTab(tab.tabId);
943
+ continue;
944
+ }
945
+ try {
946
+ const resolvedId = journeys.start(tab.journeyId, tab.input);
947
+ if (resolvedId !== tab.instanceId) {
948
+ tabsStore.getState().updateTab(tab.tabId, { instanceId: resolvedId });
949
+ }
950
+ } catch (err) {
951
+ if (err instanceof UnknownJourneyError) {
952
+ // Raced with a concurrent unregister; same policy as the pre-check.
953
+ tabsStore.getState().removeTab(tab.tabId);
954
+ continue;
955
+ }
956
+ // A real bug (corrupted input, throwing onHydrate, invariant violation).
957
+ // Drop so the shell still boots, but warn loudly and surface it to the user.
958
+ notifyUser(`We couldn't restore tab "${tab.title}"`, err);
959
+ tabsStore.getState().removeTab(tab.tabId);
960
+ }
961
+ }
962
+ ```
963
+
964
+ Two things worth underlining:
965
+
966
+ - `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.
967
+ - **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.
968
+
969
+ ## Rendering — `JourneyOutlet`
970
+
971
+ ### Props
972
+
973
+ ```ts
974
+ interface JourneyOutletProps {
975
+ /** Runtime override — usually inherited from <JourneyProvider>. */
976
+ runtime?: JourneyRuntime;
977
+ /** The instance to render. Required. */
978
+ instanceId: InstanceId;
979
+ /** Module descriptors override — usually inherited from the runtime. */
980
+ modules?: Readonly<Record<string, ModuleDescriptor<any, any, any, any>>>;
981
+ /** Shown while status === 'loading'. */
982
+ loadingFallback?: ReactNode;
983
+ /** Fired once when the instance terminates. */
984
+ onFinished?: (outcome: TerminalOutcome) => void;
985
+ /** Error policy for the step's component. Default: 'abort'. */
986
+ onStepError?: (err: unknown, ctx: { step: JourneyStep }) => "abort" | "retry" | "ignore";
987
+ /** Global retry cap per instance (retries do NOT reset on step change). Default: 2. */
988
+ retryLimit?: number;
989
+ /**
990
+ * Replaces the default red notice when the current step points at a
991
+ * `(moduleId, entry)` pair the runtime doesn't resolve to a registered
992
+ * module+entry. Shells almost always want to brand this.
993
+ */
994
+ notFoundComponent?: ComponentType<JourneyOutletNotFoundProps>;
995
+ /**
996
+ * Replaces the default red notice when a step component throws.
997
+ * Receives the raw error so shells can route it through their own
998
+ * error-reporting pipeline.
999
+ */
1000
+ errorComponent?: ComponentType<JourneyOutletErrorProps>;
1001
+ }
1002
+
1003
+ interface JourneyOutletNotFoundProps {
1004
+ readonly moduleId: string;
1005
+ readonly entry: string;
1006
+ }
1007
+
1008
+ interface JourneyOutletErrorProps {
1009
+ readonly moduleId: string;
1010
+ readonly error: unknown;
1011
+ }
1012
+
1013
+ interface TerminalOutcome {
1014
+ status: "completed" | "aborted";
1015
+ payload: unknown; // value passed to complete(…) or abort(…)
1016
+ instanceId: InstanceId;
1017
+ journeyId: string;
1018
+ }
1019
+ ```
1020
+
1021
+ ### Typical usage
1022
+
1023
+ With a `<JourneyProvider>` mounted above, `instanceId` is the only required prop:
1024
+
1025
+ ```tsx
1026
+ <JourneyOutlet
1027
+ instanceId={tab.instanceId}
1028
+ loadingFallback={<LoadingSpinner />}
1029
+ onFinished={(outcome) => {
1030
+ // outcome = { status, payload, instanceId, journeyId }
1031
+ workspace.closeTab(tab.tabId);
1032
+ }}
1033
+ onStepError={(err, { step }) => "abort" | "retry" | "ignore"}
1034
+ retryLimit={2}
1035
+ />
1036
+ ```
1037
+
1038
+ Without the provider (or when you want to point at a different runtime), pass `runtime` and optionally `modules` explicitly — they always win over context:
1039
+
1040
+ ```tsx
1041
+ <JourneyOutlet
1042
+ runtime={manifest.journeys}
1043
+ instanceId={tab.instanceId}
1044
+ modules={manifest.moduleDescriptors}
1045
+ // …
1046
+ />
1047
+ ```
1048
+
1049
+ What it does:
1050
+
1051
+ 1. Subscribes to the instance via `useSyncExternalStore`.
1052
+ 2. Renders `loadingFallback` while the async persistence `load` is in flight.
1053
+ 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? }`.
1054
+ 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.
1055
+ 5. Fires `onFinished` exactly once when the instance terminates; the outcome carries `{ status, payload, instanceId, journeyId }` so analytics can correlate without re-reading props.
1056
+ 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`).
1057
+
1058
+ ### Error policies in depth
1059
+
1060
+ `onStepError` runs on every thrown error during a step's render or effects. Pick a policy per error:
1061
+
1062
+ | Policy | Behavior |
1063
+ | ---------- | --------------------------------------------------------------------------------------------------- |
1064
+ | `'abort'` | Default. The outlet calls `runtime.end(id, { reason: 'component-error', error })`. |
1065
+ | `'retry'` | Re-mount the step with a fresh React key. Counted against `retryLimit` per instance (not per step). |
1066
+ | `'ignore'` | Keep the module error boundary UI in place until the user transitions away via another action. |
1067
+
1068
+ 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.
1069
+
1070
+ ### Outlet hosts — rules of thumb
1071
+
1072
+ - Render the outlet wherever the step should live — tab body, modal body, route element, panel, wizard card. It doesn't care.
1073
+ - 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.
1074
+ - 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.
1075
+
1076
+ ## Hosting plain modules — `ModuleTab`
1077
+
1078
+ `<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`).
1079
+
1080
+ ### Props
1081
+
1082
+ ```ts
1083
+ interface ModuleTabProps<TInput = unknown> {
1084
+ module: ModuleDescriptor<any, any, any, any>;
1085
+ entry?: string; // defaults to the module's sole entry when unambiguous
1086
+ input?: TInput;
1087
+ tabId?: string; // opaque passthrough for the onExit callback
1088
+ onExit?: (event: {
1089
+ moduleId: string;
1090
+ entry: string;
1091
+ exit: string;
1092
+ output: unknown;
1093
+ tabId?: string;
1094
+ }) => void;
1095
+ }
1096
+ ```
1097
+
1098
+ ### Behavior
1099
+
1100
+ - 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.
1101
+ - `exit(name, output)` calls `onExit` first (for the per-tab override — typically "close this tab"), then the provider-level `onModuleExit` (for analytics / global telemetry).
1102
+ - When a module predates entry points and only declares a legacy `component`, `<ModuleTab>` renders that — entry/exit contracts are strictly opt-in.
1103
+
1104
+ ```tsx
1105
+ function TabContent({ tab, moduleDescriptors, workspace }: Props) {
1106
+ if (tab.kind === "module") {
1107
+ return (
1108
+ <ModuleTab
1109
+ module={moduleDescriptors[tab.moduleId]}
1110
+ entry={tab.entry}
1111
+ input={tab.input}
1112
+ tabId={tab.tabId}
1113
+ onExit={(ev) => workspace.closeTab(tab.tabId)}
1114
+ />
1115
+ );
1116
+ }
1117
+ return (
1118
+ <JourneyOutlet instanceId={tab.instanceId} onFinished={() => workspace.closeTab(tab.tabId)} />
1119
+ );
1120
+ }
1121
+ ```
1122
+
1123
+ ## Observation hooks
1124
+
1125
+ ```ts
1126
+ defineJourney<…>()({
1127
+ onTransition: (ev) => analytics.track('journey.step', ev),
1128
+ onAbandon: ({ step }) => step?.moduleId === 'billing'
1129
+ ? { abort: { reason: 'payment-abandoned' } }
1130
+ : { abort: { reason: 'abandoned' } },
1131
+ onComplete: (ctx, result)=> analytics.track('journey.complete', { ctx, result }),
1132
+ onAbort: (ctx, reason)=> analytics.track('journey.abort', { ctx, reason }),
1133
+ onHydrate: (blob) => migrateIfNeeded(blob),
1134
+ });
1135
+ ```
1136
+
1137
+ `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.
1138
+
1139
+ 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.
1140
+
1141
+ ## Testing
1142
+
1143
+ ### Module-level — `renderModule({ entry, exit })`
1144
+
1145
+ No journey runtime involved; the `exit` callback is a test spy.
1146
+
1147
+ ```ts
1148
+ import { renderModule } from "@react-router-modules/testing"; // or @tanstack-react-modules/testing
1149
+
1150
+ const exit = vi.fn();
1151
+ await renderModule(accountModule, {
1152
+ entry: "review",
1153
+ input: { customerId: "C-1" },
1154
+ exit,
1155
+ deps: {
1156
+ /* … */
1157
+ },
1158
+ });
1159
+ // assert UI, click buttons, assert exit was called with the right (name, output)
1160
+ ```
1161
+
1162
+ ### Journey-level pure — `simulateJourney`
1163
+
1164
+ 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.
1165
+
1166
+ ```ts
1167
+ import { simulateJourney } from "@modular-react/journeys/testing";
1168
+
1169
+ const sim = simulateJourney(customerOnboardingJourney, { customerId: "C-1" });
1170
+ // `currentStep` is `step` with a non-null assertion baked in: throws if the
1171
+ // journey has terminated, so test assertions on the live path stay terse.
1172
+ // Use `sim.step` (which is `JourneyStep | null`) when a null is expected.
1173
+ expect(sim.currentStep.moduleId).toBe("profile");
1174
+
1175
+ sim.fireExit("profileComplete", {
1176
+ customerId: "C-1",
1177
+ hint: { suggestedTier: "pro", rationale: "12 seats" },
1178
+ });
1179
+ expect(sim.currentStep.moduleId).toBe("plan");
1180
+ expect(sim.state.hint?.suggestedTier).toBe("pro");
1181
+
1182
+ sim.fireExit("choseStandard", { plan: { tier: "pro", monthly: 79 } });
1183
+ expect(sim.currentStep.moduleId).toBe("billing");
1184
+
1185
+ // Initial start + two hops since the simulator started.
1186
+ expect(sim.transitions).toHaveLength(3);
1187
+ expect(sim.transitions.at(-1)!.to?.moduleId).toBe("billing");
1188
+
1189
+ // Once the journey terminates, `sim.terminalPayload` mirrors the value
1190
+ // passed to `complete` / `abort`; `sim.serialize()` returns the exact blob
1191
+ // shape a persistence adapter would see (useful for pinning round-trip
1192
+ // invariants without reaching into runtime internals).
1193
+ sim.fireExit("paid", { reference: "PAY-1", amount: 79 });
1194
+ expect(sim.terminalPayload).toEqual({ kind: "paid", reference: "PAY-1", amount: 79 });
1195
+ expect(sim.serialize().status).toBe("completed");
1196
+ ```
1197
+
1198
+ ### Integration — `renderJourney`
1199
+
1200
+ Mounts `<JourneyOutlet>` inside a minimal registry.
1201
+
1202
+ ```ts
1203
+ import { renderJourney } from "@react-router-modules/testing";
1204
+
1205
+ const { getByText, runtime, instanceId } = renderJourney(customerOnboardingJourney, {
1206
+ modules: [profileModule, planModule, billingModule],
1207
+ input: { customerId: "C-1" },
1208
+ deps: {
1209
+ /* … */
1210
+ },
1211
+ });
1212
+ ```
1213
+
1214
+ ### Common assertion shapes
1215
+
1216
+ ```ts
1217
+ // A journey finishes with a specific payload
1218
+ sim.fireExit("paid", { reference: "PAY-123", amount: 100 });
1219
+ expect(sim.status).toBe("completed");
1220
+ expect(sim.state.outcome).toEqual({ kind: "paid", reference: "PAY-123", amount: 100 });
1221
+
1222
+ // An abandon path
1223
+ sim.end({ reason: "shift-ended" });
1224
+ expect(sim.status).toBe("aborted");
1225
+
1226
+ // goBack restores state (allowBack: 'rollback')
1227
+ sim.fireExit("choseStandard", { plan: PRO });
1228
+ expect(sim.state.selectedPlan).toEqual(PRO);
1229
+ sim.goBack();
1230
+ expect(sim.state.selectedPlan).toBeNull();
1231
+
1232
+ // Analytics ordering
1233
+ expect(sim.transitions.map((t) => t.exit)).toEqual(["profileComplete", "choseStandard", "paid"]);
1234
+ ```
1235
+
1236
+ ### Testing persistence adapters
1237
+
1238
+ Drive the adapter with a fake storage map:
1239
+
1240
+ ```ts
1241
+ const store = new Map<string, string>();
1242
+ const persistence = defineJourneyPersistence<MyInput, MyState>({
1243
+ keyFor: ({ journeyId, input }) => `journey:${input.customerId}:${journeyId}`,
1244
+ load: (k) => {
1245
+ const raw = store.get(k);
1246
+ return raw ? JSON.parse(raw) : null;
1247
+ },
1248
+ save: (k, b) => {
1249
+ store.set(k, JSON.stringify(b));
1250
+ },
1251
+ remove: (k) => {
1252
+ store.delete(k);
1253
+ },
1254
+ });
1255
+
1256
+ // assert `store` entries after key transitions
1257
+ ```
1258
+
1259
+ 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.
1260
+
1261
+ ## Integration patterns
1262
+
1263
+ Journeys are container-agnostic. Four common integration shapes:
1264
+
1265
+ ### Pattern — tabbed workspace (recommended)
1266
+
1267
+ 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.
1268
+
1269
+ ```tsx
1270
+ function TabContent({ tab }: { tab: Tab }) {
1271
+ if (tab.kind === "journey") {
1272
+ return (
1273
+ <JourneyOutlet
1274
+ instanceId={tab.instanceId}
1275
+ loadingFallback={<Spinner />}
1276
+ onFinished={() => workspace.closeTab(tab.tabId)}
1277
+ />
1278
+ );
1279
+ }
1280
+ return (
1281
+ <ModuleTab
1282
+ module={descriptors[tab.moduleId]}
1283
+ entry={tab.entry}
1284
+ input={tab.input}
1285
+ tabId={tab.tabId}
1286
+ onExit={() => workspace.closeTab(tab.tabId)}
1287
+ />
1288
+ );
1289
+ }
1290
+ ```
1291
+
1292
+ 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.
1293
+
1294
+ ### Pattern — modal-hosted journey
1295
+
1296
+ For a one-shot flow that should block the rest of the UI (KYC top-up, mandatory re-auth):
1297
+
1298
+ ```tsx
1299
+ function JourneyModal({ journeyId, input, onClose }: Props) {
1300
+ const runtime = useJourneyContext()!.runtime;
1301
+ const [instanceId] = useState(() => runtime.start(journeyId, input));
1302
+
1303
+ return (
1304
+ <Dialog open onClose={onClose}>
1305
+ <JourneyOutlet instanceId={instanceId} loadingFallback={<Spinner />} onFinished={onClose} />
1306
+ </Dialog>
1307
+ );
1308
+ }
1309
+ ```
1310
+
1311
+ 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.
1312
+
1313
+ ### Pattern — full-page route
1314
+
1315
+ 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.
1316
+
1317
+ ```tsx
1318
+ // react-router element:
1319
+ <Route path="/onboarding/:customerId" element={<OnboardingPage />} />;
1320
+
1321
+ // OnboardingPage:
1322
+ function OnboardingPage() {
1323
+ const { customerId } = useParams();
1324
+ const runtime = useJourneyContext()!.runtime;
1325
+ const [instanceId] = useState(() => runtime.start("customer-onboarding", { customerId }));
1326
+ return <JourneyOutlet instanceId={instanceId} onFinished={() => nav("/")} />;
1327
+ }
1328
+ ```
1329
+
1330
+ ### Pattern — embedded wizard panel
1331
+
1332
+ 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.
1333
+
1334
+ ```tsx
1335
+ <aside style={{ display: collapsed ? "none" : "block" }}>
1336
+ <JourneyOutlet instanceId={instanceId} />
1337
+ </aside>
1338
+ ```
1339
+
1340
+ Only unmount the outlet when you truly want to abandon.
1341
+
1342
+ ### Pattern — command-palette launcher
1343
+
1344
+ 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:
1345
+
1346
+ ```ts
1347
+ palette.register("onboarding:start", async ({ customerId }) => {
1348
+ workspace.openTab({ kind: "journey", id: "customer-onboarding", input: { customerId } });
1349
+ });
1350
+ ```
1351
+
1352
+ ## Debugging
1353
+
1354
+ The runtime enables dev-mode logs automatically when `process.env.NODE_ENV !== 'production'`. Signals to watch for:
1355
+
1356
+ | Message | Meaning |
1357
+ | -------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------- |
1358
+ | `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. |
1359
+ | `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. |
1360
+ | `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. |
1361
+ | `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. |
1362
+ | `onTransition / onAbandon / onComplete / onAbort threw` | Observation hook exception. Caught; the transition still commits. Fix the hook. |
1363
+ | `persistence.load / save / remove rejected / threw` | Storage error. Transitions continue in memory; the last good blob stays on disk. |
1364
+ | `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. |
1365
+
1366
+ To introspect a running journey by hand:
1367
+
1368
+ ```ts
1369
+ const runtime = manifest.journeys;
1370
+ const ids = runtime.listInstances();
1371
+ for (const id of ids) {
1372
+ const inst = runtime.getInstance(id);
1373
+ console.log(id, inst?.status, inst?.step, inst?.history.length);
1374
+ }
1375
+ ```
1376
+
1377
+ For a fully-headless trace, drive a scenario through `simulateJourney` and inspect `sim.transitions`.
1378
+
1379
+ ## Errors, races, and edge cases
1380
+
1381
+ - **Two exits in rapid succession** — step tokens guarantee the first wins; later calls are dropped.
1382
+ - **Exit fired from an unmounted component** — same mechanism: token mismatch, drop.
1383
+ - **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.
1384
+ - **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.
1385
+ - **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.
1386
+ - **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.
1387
+ - **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.
1388
+ - **Hydrate blob whose `rollbackSnapshots` length disagrees with `history`** — rejected with `JourneyHydrationError`. Use `onHydrate` to migrate or pad the blob.
1389
+ - **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.
1390
+ - **Circular transitions** — allowed; `history` grows. Long-running journeys should use `maxHistory` or be designed to terminate.
1391
+ - **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.
1392
+ - **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.
1393
+
1394
+ ## Limitations
1395
+
1396
+ Things that aren't implemented today but may land later — these are gaps, not architectural choices.
1397
+
1398
+ - **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).
1399
+ - **No sub-journeys.** Branches only — a transition can choose between two next steps, but it can't compose another whole journey as a subroutine.
1400
+
1401
+ Cross-references for things that are sometimes mistaken for limitations:
1402
+
1403
+ - _"Transitions can't be async."_ True, by design — see [Transition handlers are pure and synchronous](#transition-handlers-are-pure-and-synchronous).
1404
+ - _"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).
1405
+ - _"`history` grows unbounded by default."_ Configurable — see [Pattern — bounded history (`maxHistory`)](#pattern--bounded-history-maxhistory) and the rollback-snapshot caveat there.
1406
+ - _"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).
1407
+ - _"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).
1408
+
1409
+ ## TypeScript inference notes
1410
+
1411
+ 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:
1412
+
1413
+ ### `defineJourney` is curried
1414
+
1415
+ ```ts
1416
+ export const journey = defineJourney<MyModules, MyState>()({
1417
+ initialState: (input: { customerId: string }) => ({
1418
+ /* … */
1419
+ }),
1420
+ // ^^^^^ TInput is inferred from `initialState`'s parameter
1421
+ });
1422
+ ```
1423
+
1424
+ `TModules` and `TState` are supplied explicitly; `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.
1425
+
1426
+ ### The module type map is per-journey, not global
1427
+
1428
+ ```ts
1429
+ type OnboardingModules = {
1430
+ readonly profile: typeof profileModule;
1431
+ readonly plan: typeof planModule;
1432
+ readonly billing: typeof billingModule;
1433
+ };
1434
+ ```
1435
+
1436
+ 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.
1437
+
1438
+ ### `StepSpec` is a discriminated union
1439
+
1440
+ `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)).
1441
+
1442
+ ### `schema<T>()` is a type brand, not a validator
1443
+
1444
+ ```ts
1445
+ input: schema<{ customerId: string }>();
1446
+ ```
1447
+
1448
+ 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.
1449
+
1450
+ ### `defineJourneyPersistence<TInput, TState>` ties the adapter to the journey
1451
+
1452
+ ```ts
1453
+ const persistence = defineJourneyPersistence<OnboardingInput, OnboardingState>({
1454
+ keyFor: ({ input }) => `journey:${input.customerId}:onboarding`, // input typed as OnboardingInput
1455
+ save: (k, b) => api.save(k, b), // b typed as SerializedJourney<OnboardingState>
1456
+ load: (k) => api.load(k),
1457
+ remove: (k) => api.remove(k),
1458
+ });
1459
+ ```
1460
+
1461
+ Without the helper, `input` on `keyFor` is `unknown`; with it, every callback is end-to-end typed.
1462
+
1463
+ ## API reference
1464
+
1465
+ Every export you're likely to call, grouped by role.
1466
+
1467
+ ### From `@modular-react/core` (module authors)
1468
+
1469
+ | Export | Signature | Purpose |
1470
+ | -------------------------------- | -------------------------------------------------------------------------------- | ----------------------------------------------------------------------- |
1471
+ | `defineEntry` | `<T>(e: ModuleEntryPoint<T>) => ModuleEntryPoint<T>` | Identity helper for an entry-point literal. Zero runtime cost. |
1472
+ | `defineExit` | `<T = void>(s?: ExitPointSchema<T>) => ExitPointSchema<T>` | Identity helper for an exit-point literal. Zero runtime cost. |
1473
+ | `schema` | `<T>() => InputSchema<T>` | Type-only brand used to carry an input/output shape. Zero runtime cost. |
1474
+ | `ModuleEntryProps` | `<TInput, TExits extends ExitPointMap = {}>` | Typed props for an entry component: `{ input, exit, goBack? }`. |
1475
+ | `ModuleEntryPoint` | `{ component, input?, allowBack? }` | Entry-point descriptor shape. |
1476
+ | `ExitPointSchema` | `{ output? }` | Exit-point descriptor shape. |
1477
+ | `ExitFn` | `<TExits>(name, output?) => void` | The function signature `exit` gets on an entry component. |
1478
+ | `EntryPointMap` / `ExitPointMap` | `Record<string, ModuleEntryPoint<any>>` / `Record<string, ExitPointSchema<any>>` | Map shapes on `ModuleDescriptor`. |
1479
+
1480
+ ### Authoring (`@modular-react/journeys`)
1481
+
1482
+ | Export | Signature | Purpose |
1483
+ | ----------------------------- | ------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------- |
1484
+ | `defineJourney` | `<TModules, TState>() => <TInput>(def: JourneyDefinition<TModules, TState, TInput>) => def` | Identity helper with full inference on transitions and state. Curried so `TInput` infers from `initialState`. |
1485
+ | `defineJourneyHandle` | `<TModules, TState, TInput>(def) => JourneyHandle<string, TInput>` | 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. |
1486
+ | `defineJourneyPersistence` | `<TInput, TState>(adapter) => JourneyPersistence<TState, TInput>` | Types `keyFor`'s `input` against `TInput`, `load`/`save` against `TState`. |
1487
+ | `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. |
1488
+ | `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. |
1489
+
1490
+ ### Rendering + context (`@modular-react/journeys`)
1491
+
1492
+ | Export | Purpose |
1493
+ | ------------------- | ------------------------------------------------------------------------------------------------------------------ |
1494
+ | `JourneyProvider` | Context provider for the runtime and optional `onModuleExit`. Mount once at the shell root. |
1495
+ | `useJourneyContext` | Reads the current provider value, or `null`. |
1496
+ | `JourneyOutlet` | Renders the current step of a journey instance. Handles loading, error boundary, terminal, and abandon-on-unmount. |
1497
+ | `ModuleTab` | Renders a single module entry outside a route. Non-journey counterpart to `JourneyOutlet`. |
1498
+
1499
+ ### Runtime + validation (`@modular-react/journeys`)
1500
+
1501
+ | Export | Purpose |
1502
+ | --------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
1503
+ | `createJourneyRuntime` | Low-level runtime factory. Normally called by the registry; exported for advanced use (test harnesses, custom hosts). |
1504
+ | `validateJourneyContracts` | Cross-checks a journey's transitions against registered modules. Runs automatically at `resolveManifest()` / `resolve()`; exported for custom validation flows. |
1505
+ | `validateJourneyDefinition` | Structural sanity check on a definition's own shape. Runs automatically in `registerJourney`. |
1506
+ | `JourneyValidationError` | Aggregated validation error. `.issues: readonly string[]`. |
1507
+ | `JourneyHydrationError` | Thrown from `hydrate` / async-load when the blob is unusable. |
1508
+ | `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. |
1509
+
1510
+ ### Runtime methods (the `JourneyRuntime` returned as `manifest.journeys`)
1511
+
1512
+ | Method | Description |
1513
+ | --------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------- |
1514
+ | `start(handle, input)` | **Preferred.** Start or resume an instance via a handle (`defineJourneyHandle`); `input` is type-checked end-to-end. Idempotent per persistence key. |
1515
+ | `start(journeyId, input)` | String-id form for dynamic dispatch (e.g. navbar `{ kind: "journey-start", journeyId }`). Accepts any `input`. |
1516
+ | `hydrate(journeyId, blob)` | Explicit read-only hydrate. Persistence-unlinked. Returns `InstanceId`. |
1517
+ | `getInstance(id)` | Current snapshot of an instance, or `null`. Stable-identity between changes (for `useSyncExternalStore`). |
1518
+ | `listInstances()` / `listDefinitions()` | Enumerate. Useful for admin tooling. |
1519
+ | `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. |
1520
+ | `subscribe(id, listener)` | Subscribe to change notifications for one instance. Returns unsubscribe. |
1521
+ | `end(id, reason?)` | Force-terminate. Fires `onAbandon` if active; treats `loading` as a direct abort without firing `onAbandon`. |
1522
+ | `forget(id)` / `forgetTerminal()` | Drop terminal instances from memory. `forget` is a no-op on active/loading; `forgetTerminal` batches them all. |
1523
+
1524
+ ### Registration options (passed to `registry.registerJourney`)
1525
+
1526
+ ```ts
1527
+ interface JourneyRegisterOptions<TState = unknown, TInput = unknown> {
1528
+ /**
1529
+ * Fires after every transition, in addition to the definition's
1530
+ * `onTransition`. Useful for shell telemetry that doesn't belong in
1531
+ * journey authoring code.
1532
+ */
1533
+ onTransition?: (ev: TransitionEvent) => void;
1534
+ /**
1535
+ * Fires when the journey reaches `{ complete }`. Runs after the
1536
+ * definition-level `onComplete` (both fire). Shell-level completion
1537
+ * analytics belong here.
1538
+ */
1539
+ onComplete?: (ctx: TerminalCtx<TState>, result: unknown) => void;
1540
+ /**
1541
+ * Fires on abort (via `{ abort }` transition, a thrown handler, or
1542
+ * `runtime.end(id)`). Runs after the definition-level `onAbort`.
1543
+ */
1544
+ onAbort?: (ctx: TerminalCtx<TState>, reason: unknown) => void;
1545
+ /**
1546
+ * Overrides the definition's `onAbandon` when `runtime.end(id)` is
1547
+ * called on an active instance. Use to swap abandon behaviour for a
1548
+ * specific deployment (e.g. "save as completed on end-of-shift" vs
1549
+ * the journey author's default "abort").
1550
+ */
1551
+ onAbandon?: (ctx: AbandonCtx) => TransitionResult;
1552
+ /**
1553
+ * Layered on top of the definition-level `onHydrate` — runs **after**
1554
+ * the definition transforms the blob. Useful for shell-level migrations
1555
+ * the journey author doesn't know about (redacting env-specific ids on
1556
+ * load, etc.).
1557
+ */
1558
+ onHydrate?: (blob: SerializedJourney<TState>) => SerializedJourney<TState>;
1559
+ /**
1560
+ * Observation-only error hook. Fires whenever a step component throws
1561
+ * or a transition handler throws for an instance of this journey. The
1562
+ * runtime still aborts / retries according to the outlet's
1563
+ * `onStepError` policy — use this for telemetry, not control flow.
1564
+ */
1565
+ onError?: (err: unknown, ctx: { step: JourneyStep | null }) => void;
1566
+ /**
1567
+ * Optional. Without it, journeys live in memory only — every
1568
+ * `runtime.start()` mints a fresh instance and nothing is written to
1569
+ * storage.
1570
+ */
1571
+ persistence?: JourneyPersistence<TState>;
1572
+ /**
1573
+ * Maximum `history` entries retained (oldest dropped). See the caveat
1574
+ * with `allowBack` below.
1575
+ */
1576
+ maxHistory?: number;
1577
+ /**
1578
+ * Optional nav contribution. When set, the journeys plugin emits a
1579
+ * navigation item for this journey so pure launchers don't need a
1580
+ * shadow module to host them. The contributed item carries
1581
+ * `action: { kind: "journey-start", journeyId, buildInput }`; the
1582
+ * shell's navbar dispatcher starts the journey on click.
1583
+ */
1584
+ nav?: JourneyNavContribution<TInput>;
1585
+ }
1586
+
1587
+ interface JourneyNavContribution<TInput = unknown> {
1588
+ label: string;
1589
+ icon?: string | React.ComponentType<{ className?: string }>;
1590
+ group?: string;
1591
+ order?: number;
1592
+ hidden?: boolean;
1593
+ meta?: unknown;
1594
+ /** Builds the journey's `input` at click time. Typed against `TInput`. */
1595
+ buildInput?: (ctx?: unknown) => TInput;
1596
+ }
1597
+ ```
1598
+
1599
+ **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?.())`.
1600
+
1601
+ 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:
1602
+
1603
+ ```ts
1604
+ journeysPlugin<AppNavItem>({
1605
+ buildNavItem: (defaults, raw) => ({
1606
+ ...defaults,
1607
+ meta: { analytics: `launch-${raw.journeyId}` },
1608
+ }),
1609
+ });
1610
+ ```
1611
+
1612
+ 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`.
1613
+
1614
+ ### Serialized shape (persistence)
1615
+
1616
+ ```ts
1617
+ interface SerializedJourney<TState> {
1618
+ definitionId: string;
1619
+ version: string;
1620
+ instanceId: string;
1621
+ status: "active" | "completed" | "aborted";
1622
+ step: { moduleId: string; entry: string; input: unknown } | null;
1623
+ history: ReadonlyArray<{ moduleId: string; entry: string; input: unknown }>;
1624
+ /** Index-aligned with `history`; `null` for entries without a rollback snapshot. */
1625
+ rollbackSnapshots?: ReadonlyArray<TState | null>;
1626
+ /** Present only on terminal blobs — mirrors the transition's `complete`/`abort` payload. */
1627
+ terminalPayload?: unknown;
1628
+ state: TState;
1629
+ startedAt: string;
1630
+ updatedAt: string;
1631
+ }
1632
+ ```
1633
+
1634
+ ### Testing (`@modular-react/journeys/testing`)
1635
+
1636
+ | Export | Purpose |
1637
+ | -------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
1638
+ | `simulateJourney` | Headless simulator: fires exits / goBack, exposes `step` / `currentStep` (throws if terminal) / `state` / `history` / `status` / `transitions` / `terminalPayload` / `serialize()`, no React. |
1639
+ | `JourneySimulator` | Type for the object returned by `simulateJourney`. |
1640
+ | `createTestHarness` | Wraps a live `JourneyRuntime` so tests can fire exits, call `goBack`, and inspect instance internals without mounting `<JourneyOutlet>`. Replaces reaching for `getInternals` directly. |
1641
+ | `JourneyTestHarness` | Type returned by `createTestHarness`. |
1642
+
1643
+ ### From the router runtime packages
1644
+
1645
+ | Export | Purpose |
1646
+ | ----------------------------------------- | ----------------------------------------------------------------------------------------------------------- |
1647
+ | `registry.registerJourney(def, options?)` | Adds a journey to the registry. Structural check runs immediately; contract check at resolve time. |
1648
+ | `manifest.journeys` | The `JourneyRuntime` bound to the resolved registry. Always non-null (no-op when no journey is registered). |
1649
+ | `manifest.moduleDescriptors` | Map of module id → descriptor. Consumed by `<ModuleTab>` and by any code rendering a module entry directly. |
1650
+ | `ResolveManifestOptions.onModuleExit` | Shell-level fallback for module exits fired through `<ModuleTab>`. Wire to analytics or global tab-close. |
1651
+
1652
+ ### Exported types (for annotations and adapters)
1653
+
1654
+ `JourneyDefinition`, `TransitionMap`, `EntryTransitions`, `StepSpec`, `TransitionResult`, `ExitCtx`, `JourneyInstance`, `JourneyStatus`, `JourneyStep`, `JourneyDefinitionSummary`, `SerializedJourney`, `JourneyRuntime`, `JourneyRuntimeOptions`, `JourneyRegisterOptions`, `JourneyNavContribution`, `JourneyPersistence`, `JourneyHandle`, `ModuleTypeMap`, `EntryInputOf`, `EntryNamesOf`, `ExitNamesOf`, `ExitOutputOf`, `TransitionEvent`, `AbandonCtx`, `TerminalCtx`, `TerminalOutcome`, `InstanceId`, `AnyJourneyDefinition`, `RegisteredJourney`, `MaybePromise`, `JourneyProviderProps`, `JourneyProviderValue`, `JourneyOutletProps`, `JourneyOutletNotFoundProps`, `JourneyOutletErrorProps`, `JourneyStepErrorPolicy`, `ModuleTabProps`, `ModuleTabExitEvent`, `JourneyDefaultNavItem`, `JourneyNavItemBuilder`, `JourneysPluginOptions`, `JourneysPluginExtension`.
1655
+
1656
+ ## Example projects
1657
+
1658
+ Complete, runnable walk-throughs live under `examples/`:
1659
+
1660
+ - [`examples/react-router/customer-onboarding-journey/`](../../examples/react-router/customer-onboarding-journey/) — React Router integration.
1661
+ - [`examples/tanstack-router/customer-onboarding-journey/`](../../examples/tanstack-router/customer-onboarding-journey/) — TanStack Router integration with the same modules and journey.
1662
+
1663
+ Each example demonstrates:
1664
+
1665
+ - `defineEntry` / `defineExit` across three modules (profile, plan, billing).
1666
+ - `defineJourney` composing them with typed transitions and a shared state.
1667
+ - `registry.registerJourney(...)` with a localStorage persistence adapter — reload the page mid-flow and the tab resumes at the last step.
1668
+ - A minimal tabbed shell mounting `<JourneyOutlet>` and `<ModuleTab>` side-by-side.
1669
+ - `WorkspaceActions.openTab({ kind: 'journey', … })` as the shell-facing API, with `openModuleTab` kept as a `@deprecated` shim.