@modular-vue/journeys
v1.4.0
Published
Vue 3 journeys for @modular-vue: JourneyProvider, instance composables (useJourneyState / useActiveLeafJourneyInstance), and the journeys registry plugin over @modular-frontend/journeys-engine.
Readme
@modular-vue/journeys
Vue 3 journeys for @modular-vue. Journeys are typed, serializable workflows
that compose multiple modules: a journey declares entry/exit transitions between
modules and owns shared state, while the modules stay journey-unaware.
This package is the Vue binding over the framework-neutral
@modular-frontend/journeys-engine. It re-exports the
engine's authoring surface (defineJourney, handles, persistence, transition
helpers) and adds the Vue-specific pieces: the provider, the composables, the
outlet, and the registry plugin.
The React binding is @modular-react/journeys; the two are kept
at parity, and its README carries the long-form guides (authoring patterns,
invoke/resume, persistence) that apply to both.
Installation
npm install @modular-vue/journeysWhat's included
<JourneyProvider :runtime="…">— provides the journey runtime to descendant journey hosts and mounts<ModuleExitProvider>so module exits fired outside a step reach the shell'sonModuleExit.<JourneyOutlet :instance-id="…">— renders the current step of a running instance: mount-kind checks, loading, error boundary + policy, terminal, idle preload, and abandon-on-unmount. Walks the active call chain to the leaf by default.<JourneyHost :handle="…" :input="…">— mounts a journey in one line: starts it on mount, renders its step, ends + forgets the instance on unmount. Outlet props pass through as attrs, in either spelling (:on-finishedand:onFinishedboth reach the outlet); the default scoped slot receives{ instanceId, instance, runtime, stepIndex, outlet }for chrome.outletis a functional component — render it with<component :is="outlet" />, or spell it yourself as<JourneyOutlet :instance-id="instanceId" :runtime="runtime" />.useJourneyHost(handle, input, options?)— the lifecycle without the rendering. Returns{ instanceId, instance, stepIndex }as refs, plus the plainruntimeit resolved at setup — the oneinstanceIdis valid on.useJourneySync(id, port, options?)— keeps a journey and the URL in step both ways: the journey advances and the URL follows; Back/Forward driverewindTo/goForward. You supply a smallJourneySyncPortfor vue-router.useJourneyState(id)/useJourneyInstance(id)— subscribe to one journey instance; return a reactive ref of itsstate(or full snapshot).useActiveLeafJourneyState(rootId)/useActiveLeafJourneyInstance(rootId)— walk theactiveChildIdchain and track the deepest active leaf, so a parent host reads the child sub-flow's state without knowing the depth.<ModuleTab>— renders a single module entry outside a route and forwards its exits to the shell. The non-journey counterpart to the outlet.useWaitForExit(exit, channels)— for step components that wait on an async backend event: races a pushsubscribechannel, apollchannel, and atimeoutarm with first-wins-latched dispatch.journeysPlugin(options?)— pass tocreateRegistry({ plugins: [...] })to contributeregisterJourney(...), validate journey contracts against registered modules, produce theJourneyRuntimeonmanifest.extensions.journeys, and wrap the provider stack in<JourneyProvider>. In the router-owning install path it also threads the runtime app-wide via itsappProvideshook, soapp.use(manifest)providesjourneyKeywith no shell wiring.createPiniaJourneyPersistence(options)— aJourneyPersistencebacked by a Pinia store you own (keyed likecreateWebStoragePersistence), so in-flight journeys live in your Pinia tree and resume across a modal close/reopen. Structural store shape — takes nopiniadependency.provideJourneyRuntime(app, runtime, options?)— the app-level twin of<JourneyProvider>(analog ofprovideNavigation/provideSlots), for wiring a hand-built runtime without the plugin. Usually unnecessary — the plugin auto-threads viaapp.use(manifest).
Usage
// app-shared: register the plugin and journeys
import { createRegistry } from "@modular-vue/runtime";
import { journeysPlugin, defineJourney } from "@modular-vue/journeys";
const registry = createRegistry<AppDeps, AppSlots>({ stores, services, slots });
const journeys = journeysPlugin();
registry.use(journeys);
registry.registerJourney(checkoutJourney, {
nav: { label: "Checkout", group: "flows" },
});<!-- a host component reading the active leaf's state -->
<script setup lang="ts">
import { useActiveLeafJourneyInstance } from "@modular-vue/journeys";
const props = defineProps<{ rootId: string }>();
const leaf = useActiveLeafJourneyInstance(props.rootId);
</script>
<template>
<span>{{ leaf?.step?.moduleId }} — {{ leaf?.status }}</span>
</template><!-- a journey route: mounted, deep-linked, cleaned up -->
<script setup lang="ts">
import { useRoute, useRouter } from "vue-router";
import { JourneyOutlet, useJourneyHost, useJourneySync } from "@modular-vue/journeys";
import type { JourneySyncPort } from "@modular-vue/journeys";
import { checkoutHandle } from "@app/journeys-checkout";
const props = defineProps<{ cartId: string }>();
const router = useRouter();
const route = useRoute();
const { instanceId, stepIndex } = useJourneyHost(checkoutHandle, { cartId: props.cartId });
const port: JourneySyncPort = {
read: () => String(route.params.step ?? ""),
push: (path) => void router.push({ name: "checkout", params: { step: path } }),
replace: (path) => void router.replace({ name: "checkout", params: { step: path } }),
go: (delta) => router.go(delta),
subscribe: (listener) => router.afterEach(() => listener()),
};
useJourneySync(instanceId, port, { stepToPath: (step) => step.entry });
</script>
<template>
<p>Step {{ stepIndex + 1 }}</p>
<JourneyOutlet v-if="instanceId" :instance-id="instanceId" />
</template>useJourneyHost starts the journey on mount and ends + forgets it on unmount;
useJourneySync owns the URL. Neither knows about the other, and instanceId
is null for the first render (the journey is started from onMounted, so the
start is guaranteed to be paired with an unmount that ends it).
A URL cannot navigate a journey to an arbitrary step — a step is derived from state, so the only positions a location can select are ones the journey has already been to. See Deep-linking steps in the React README for the full model; the reconciler is the same code.
Reactivity
The composables mirror the React hooks but return Vue refs. A single-instance
composable subscribes at setup and pushes fresh snapshots into a shallowRef on
every runtime change; the leaf-walking composables re-subscribe as the active
chain grows (a parent invokes a child) or shrinks (a child terminates and the
parent resumes). Read .value at the call site, or bind directly in templates.
