# Journeys in Vue (`@modular-vue/journeys`) A **journey** is a typed, multi-step flow — a wizard — with back/rewind, persistence, and optional URL sync. The runtime is framework-neutral (`@modular-frontend/journeys-engine`); `@modular-vue/journeys` is the Vue binding, the faithful analog of `@modular-react/journeys`. Same names, same semantics; the differences are Vue idiom (composables return `Ref` / `ComputedRef`, components are `defineComponent`, render-prop children become scoped slots). Reach for a journey when a flow has typed steps, back/rewind, "resume where I left off", or step↔URL sync — the shape that otherwise turns into a hand-rolled `STEP_ORDER` + a bespoke store + a `resetFlowState()` you must remember to call. > **Hosting is host-agnostic.** A journey runs in a route element, a tab, a > modal, or a plain `
` — it never owns the URL unless you opt in. The > primary case in this guide is a **modal with no router involvement**. --- ## Install ```bash pnpm add @modular-vue/journeys @modular-frontend/journeys-engine ``` `@modular-vue/journeys` re-exports the entire engine surface, so you import authoring helpers and types from the binding and never reach past it: ```ts import { defineJourney, defineJourneyHandle, journeysPlugin, createWebStoragePersistence, type JourneyRuntime, } from "@modular-vue/journeys"; ``` --- ## 1. Author a journey ```ts // journeys/env-setup.ts import { defineJourney, defineJourneyHandle } from "@modular-vue/journeys"; interface EnvSetupInput { frameId: string; } interface EnvSetupState { frameId: string; picked?: string; preflightOk?: boolean; } // `defineJourney()` — `Input` is inferred from // `initialState`'s parameter annotation. export const envSetup = defineJourney()({ id: "env-setup", version: "1.0.0", initialState: ({ frameId }: EnvSetupInput) => ({ frameId }), start: (s) => ({ module: "pick", entry: "pick", input: { frameId: s.frameId } }), transitions: { pick: { pick: { chosen: (s, out) => ({ next: { module: "review", entry: "review", input: out } }) }, }, review: { review: { confirmed: () => ({ next: { module: "preflight", entry: "run", input: {} } }) }, }, preflight: { run: { ok: () => ({ next: { module: "save", entry: "save", input: {} } }) } }, save: { save: { done: () => ({ complete: { saved: true } }) } }, }, }); // A handle lets modules/shells open the journey with typed `input` without // importing the runtime. Built from the definition; runtime identity is just // its `id`. export const envSetupHandle = defineJourneyHandle(envSetup); ``` Register it through the plugin when you build the registry: ```ts import { createRegistry } from "@modular-vue/runtime"; import { journeysPlugin } from "@modular-vue/journeys"; export const registry = createRegistry({}) .use(journeysPlugin()) .register(pickModule) .register(reviewModule) .register(preflightModule) .register(saveModule); registry.registerJourney(envSetup); ``` The plugin produces a `JourneyRuntime` on `manifest.extensions.journeys`, also surfaced as the `manifest.journeys` convenience alias. --- ## 2. Host it Three components + a set of composables, all Vue-idiomatic: | Piece | Role | | ------------------------------------------------------------ | ------------------------------------------------------------------------------------------------------------ | | `` | Provide the runtime to descendant outlets. Usually `manifest.journeys`. | | `` | One-line host: start on mount, render the step, abandon on unmount. | | `` | Render the current step of an existing instance. | | `useJourneyHost(handle, input)` | Own an instance for a component's lifetime; returns reactive `{ instanceId, instance, runtime, stepIndex }`. | | `useJourneyInstance` / `useJourneyState` | Tearing-free subscription to an instance snapshot / its state. | | `useActiveLeafJourneyInstance` / `useActiveLeafJourneyState` | The same, following the active call chain to the leaf. | | `useJourneySync` | Opt-in journey↔URL reconciliation (see §6). | | `useWaitForExit` | Race async channels and dispatch a journey exit. | The one-line host with chrome via the scoped slot: ```vue ``` The slot's `outlet` is a **functional component** (not a raw VNode), so `` mounts it cleanly and patches — not remounts — it across steps. If you'd rather spell the outlet yourself, the slot also hands you `instanceId` and `runtime`, so `` is an equivalent, idiomatic alternative (pass `runtime` so the outlet resolves the same runtime the host pinned). Outlet props (`onFinished`, `onStepError`, `errorComponent`, `preload`, `leafOnly`, `retryLimit`, …) pass straight through `` to the inner ``. --- ## 3. Lifecycle rules (memorize these four) These match the React binding exactly: 1. **Start rehydrates from persistence.** `runtime.start(id, input)` is idempotent when a persistence adapter is configured: if a blob exists for `keyFor(input)`, it returns that instance instead of minting a new one. This recovers an in-flight journey across a **reload** (the fresh boot's `start()` rehydrates the persisted blob), and lets reopening the same frame resume rather than restart — _as long as the instance wasn't ended in between_ (rule 3). 2. **A host's instance is fixed for its lifetime.** `useJourneyHost` reads `handle` / `input` / `runtime` once at setup; changing `input` later does **not** restart. To run a different journey, remount with `:key`. 3. **Host and outlet END the instance on unmount (deferred one microtask).** `` and `` call `runtime.end(id, …, { force: true })` on unmount — which aborts the instance and, on that terminal, **removes its persisted blob**. The one-microtask defer lets a same-tick handoff survive (a `:key` swap, `` toggle, HMR), and the outlet **skips** the end while any listener is still subscribed to the record (`record.listeners.size > 0`). But a genuine `v-if` close is many ticks later with no remaining subscriber, so it _does_ end the journey — see §4 for how to keep one alive across a modal close. 4. **`instanceId` may be `null` on the first render.** Every composable no-ops on `null`, so you can call them unconditionally above an early return. --- ## 4. Modal-mounted journeys (no URL) The board-app case: the wizard lives in a modal opened by a store boolean, runs outside route navigation, and survives close/reopen. **No `useRoute` / `useRouter` anywhere.** ### Which host? `` is the one-liner for a **route or tab** host: it starts on mount and **ends the instance on unmount** (rule 3). That's wrong for a modal you close and reopen with `v-if` — the close unmounts the host, ends the journey, and removes its persisted blob, so reopening starts fresh. For a modal that must **survive close**, start the instance yourself and keep it alive across the outlet's unmount: - start with `runtime.start(handle.id, input)` — idempotent under persistence, so the same frame resolves to the same instance; - render a plain `` inside the `v-if` modal; - hold an always-mounted subscription to the instance (`useJourneyInstance`) so the outlet's abandon-on-unmount is skipped (it only ends when `record.listeners.size === 0`). ```ts // A composable with the runtime from context (threaded app-wide — §7). export function useWizardControls() { const ctx = useJourneyContext(); const ui = useUiStore(); function open(frameId: string) { ui.instanceId = ctx!.runtime.start(envSetupHandle.id, { frameId }); ui.isOpen = true; } return { open }; } ``` ```vue ``` ```vue ``` **Resume, two ways.** In-session close → reopen resumes the _live_ instance (the subscription kept it from being torn down). A full **reload** resumes via persistence: the fresh boot's `start()` rehydrates the blob — provided the backing store is durable (the Pinia adapter is in-memory unless you also persist the store to `localStorage`). **Close vs. cancel — the load-bearing distinction.** These look identical (both unmount the outlet) but mean opposite things for the persisted blob: - **Soft close** ("resume later"): do **nothing** to the runtime. The keep-alive subscription holds the instance active, so the blob survives and reopening resumes. This is the whole point of the keep-alive above. - **Hard cancel** ("throw it away"): call **`runtime.discard(ui.instanceId)`**. It force-ends the instance — which removes the persisted blob, as any terminal transition does — and forgets the record, in one call. No need to re-derive `persistence.keyFor(input)` and call `persistence.remove` by hand; `discard` is addressable by `instanceId`. ```ts // A "Cancel" button on the modal: drop the flow and its saved progress. function cancel() { if (ui.instanceId) ctx!.runtime.discard(ui.instanceId); ui.isOpen = false; ui.instanceId = null; } ``` `runtime.goBack(id)` rewinds a single step (not a cancel). Finishing (a terminal exit) also removes the blob and additionally fires `onFinished`. `discard` runs the journey's `onAbandon` first (so its telemetry still fires) and forces the outcome terminal, so a non-terminal `onAbandon` can't leave the instance dangling. > A complete, runnable version — a real Nuxt app with Pinia persistence and > `appProvides` threading — is in > [`examples/vue/nuxt-modal-journey`](../examples/vue/nuxt-modal-journey). --- ## 5. Persistence ### Web Storage (the 80% case) ```ts import { createWebStoragePersistence } from "@modular-vue/journeys"; const persistence = createWebStoragePersistence({ keyFor: ({ journeyId, input }) => `journey:${input.frameId}:${journeyId}`, }); registry.registerJourney(envSetup, { persistence }); ``` ### Pinia-backed persistence When you want in-flight journeys to live in your existing Pinia store tree — visible in Pinia devtools, clearable through the same `$reset` path as the rest of your state — use `createPiniaJourneyPersistence`. It is the Vue-ecosystem analog of `createWebStoragePersistence`, keyed the same way, but backed by a Pinia store **you own**. > **No Pinia dependency.** `@modular-vue/journeys` takes no `pinia` dependency > — the adapter is structural, and you pass the store in. (Tracker decision D3: > "do not take a Pinia dependency in runtime packages.") ```ts import { defineStore } from "pinia"; import { createPiniaJourneyPersistence, type SerializedJourney } from "@modular-vue/journeys"; // A tiny store whose only job is to hold serialized journeys. const useJourneyStore = defineStore("journeys", { state: () => ({ journeys: {} as Record> }), }); const persistence = createPiniaJourneyPersistence({ keyFor: ({ journeyId, input }) => `journey:${input.frameId}:${journeyId}`, // A getter, so the store is resolved inside an active Pinia scope; return // null under SSR to force the no-op path (client-only resume, like web storage). store: () => useJourneyStore(), }); registry.registerJourney(envSetup, { persistence }); ``` By default `load` returns a plain object detached from the store (safe to mutate). Pass `clone: false` to hand back the live reactive entry instead; note Pinia always wraps stored state in a reactive proxy, so even un-cloned `load` never returns the exact reference you saved. Use a custom `stateKey` if the record lives under a property other than `journeys`. Prefer the shipped adapter over hand-rolling a `JourneyPersistence` so every consumer keys and serializes identically — but if you need something bespoke, the contract is just four methods (`keyFor` / `load` / `save` / `remove`). ### Pinia store behind the neutral `Store` contract To let a Pinia store fill a registry-owned `Store` / reactive-service DI slot — the same slot a zustand or core `createStore` store fills — adapt it with `createPiniaStoreAdapter` (from `@modular-vue/vue`). This closes the deferred Store Pinia interop (tracker D3) without a parallel state layer. ```ts import { createPiniaStoreAdapter, storeRef } from "@modular-vue/vue"; const wizardStore = useWizardStore(); // a Pinia store const adapted = createPiniaStoreAdapter(wizardStore); // satisfies Store // Hand `adapted` to the registry's deps bucket, or bridge into Vue reactivity: const state = storeRef(adapted); ``` `getState` / `getInitialState` / `setState` / `subscribe` behave exactly like the built-in `createStore`: `setState(partial)` merges via `$patch`, `setState(next, true)` replaces `$state`, and `subscribe` fires synchronously with a fresh snapshot identity per change (so `useSyncExternalStore` / `storeRef` consumers see a real change signal). Again — no `pinia` dependency; the store is structural. --- ## 6. URL sync (opt-in) Modal wizards don't touch the URL. When you _do_ want step↔URL sync (a route-hosted journey, deep-linkable steps), call `useJourneySync` in the same component that owns the instance, supplying a router-neutral `JourneySyncPort`: ```ts import { useJourneySync } from "@modular-vue/journeys"; useJourneySync(instanceId, port, { basePath: "/setup" }); ``` The reconciler (`createJourneySync`) is framework- and router-neutral and lives in the engine; `useJourneySync` is only the Vue lifetime wrapper. Supply a `JourneySyncPort` that adapts your router (`read` / `push` / `replace` / `go` / `subscribe`). A vue-router port is a thin wrapper you can write against `createMemoryJourneySyncPort` as a reference — it is intentionally left to the app so the binding takes no `vue-router` dependency. --- ## 7. Nuxt / router-owning shells In the router-owning path (`installModularApp`, or any `app.use(manifest)` shell), the journey runtime is threaded **app-wide automatically** — the journeys plugin contributes its runtime through its `appProvides` hook, exactly how `navigation` / `modules` / `slots` reach the app there. So a `` mounted anywhere under `` resolves the runtime with **no** `` wrap: ```ts // plugins/modular.client.ts export default defineNuxtPlugin((nuxtApp) => { const registry = buildRegistry(); // .use(journeysPlugin()) const manifest = installModularApp(nuxtApp, registry, { parentRouteName: "app" }); return { provide: { modular: manifest } }; // app.use(manifest) — done by installModularApp — already provides journeyKey. }); ``` `installModularApp` stays journey-unaware: an app that registers no `journeysPlugin()` provides nothing extra. When you need to wire a runtime **without** the plugin (a hand-built runtime, a second app under SSR, or a subtree override), use the explicit `provideJourneyRuntime(app, runtime)` helper — the journeys analog of `provideNavigation` / `provideSlots`. In the framework-mode component path (`resolveManifest`), the plugin's `` is threaded into the `Providers` stack instead, so the same "no hand-wiring" property holds — this mirrors the React runtime, which wraps its tree in `` in every mode. --- ## 8. Testing `@modular-vue/journeys/testing` re-exports the engine's headless drivers (`createTestHarness`, `simulateJourney`), mirroring `@modular-react/journeys/testing`, so journey step logic is unit-testable without mounting a component: ```ts import { createTestHarness } from "@modular-vue/journeys/testing"; ``` --- ## See also - [Framework-Mode Integration (Nuxt 4)](./framework-mode-nuxt.md) — the router-owning install path this guide's §7 builds on. - [Vue support tracker](./vue-support-tracker.md) — decisions D3 (Pinia interop) and D4 (authoring style) behind this binding. - `@modular-react/journeys` — the reference React binding these APIs mirror.