@xmachines/play-vue-router
API / @xmachines/play-vue-router
Vue Router 4.x adapter for the XMachines Universal Player Architecture. It keeps Vue Router and an XMachines state machine in step, in both directions, with the reactive primitives of Vue.
Installation
pnpm add @xmachines/play-vue-routerPeer dependencies:
vue^3.5.0 — Vue runtime@vue/reactivity^3.5.0 — Vue reactivity primitivesvue-router^4.0.0 || ^5.0.0 — Vue Router libraryxstate^5.31.0 — XState v5 state machine runtime
Usage
VueRouterBridge — low-level adapter
VueRouterBridge connects the currentRoute ref of Vue Router to the currentRoute signal of an XMachines actor. Both directions are active: the actor drives the URL, and the URL drives the actor.
import { createRouter, createWebHistory } from "vue-router";import { h } from "vue";import { VueRouterBridge, RouteMap } from "@xmachines/play-vue-router";import { definePlayer } from "@xmachines/play-xstate";import { machine } from "./machine.js"; // your routable machine (states carry meta.route)
// 1. Define routes — a single catch-all with a stub host component;// PlayRenderer picks the actual view from actor stateconst RouteHost = { render: () => h("div") };
const router = createRouter({ history: createWebHistory(), routes: [{ path: "/:pathMatch(.*)*", name: "xmachines-play", component: RouteHost }],});
// 2. Create a bidirectional state ID ↔ path mappingconst routeMap = new RouteMap([ { stateId: "home", path: "/" }, { stateId: "profile", path: "/profile/:userId" }, { stateId: "settings", path: "/settings/:section?" },]);
// 3. Start the actor, then the bridge after the router is readyconst actor = definePlayer({ machine })();actor.start();
await router.isReady();const bridge = new VueRouterBridge(router, actor, routeMap);bridge.connect();
// 4. Dispose when tearing down (e.g. onUnmounted)bridge.dispose();PlayRouterProvider — Vue component wrapper
PlayRouterProvider manages the bridge lifecycle for you. It calls bridge.connect() on mount, after router.isReady(). It calls bridge.disconnect() on unmount.
<script setup lang="ts">import { h, markRaw } from "vue";import { useRouter } from "vue-router";import { PlayRouterProvider, RouteMap } from "@xmachines/play-vue-router";import { definePlayer } from "@xmachines/play-xstate";import { machine } from "./machine.js"; // your routable machine (states carry meta.route)// AppShell: your root component — a real app renders PlayUIProvider + PlayRenderer// from @xmachines/play-vue (see the workspace-only @xmachines/play-vue-demo Shell)import AppShell from "./AppShell.vue";
const router = useRouter();const routeMap = new RouteMap([ { stateId: "home", path: "/" }, { stateId: "profile", path: "/profile/:userId" },]);
// markRaw prevents Vue from wrapping the actor in a reactive proxy,// which would break TC39 Signal receivers.const actor = markRaw(definePlayer({ machine })());actor.start();</script>
<template> <PlayRouterProvider :actor="actor" :router="router" :routeMap="routeMap" :renderer="(actor, router) => h(AppShell, { actor, router })" /></template>Sending route events from components
<script setup>import { inject } from "vue";
// Provided at app setup with the matching call: app.provide("actor", actor)const actor = inject("actor");
function viewProfile(userId) { actor.send({ type: "play.route", to: "#profile", params: { userId } });}</script>
<template> <button @click="viewProfile('123')">View Profile</button></template>API Reference
VueRouterBridge
This class implements the RouterBridge protocol. It watches the currentRoute shallowRef of Vue Router and the currentRoute TC39 Signal of the actor.
class VueRouterBridge { constructor(vueRouter: Router, actor: RoutableActor, routeMap: RouteMap); connect(): void; disconnect(): void; dispose(): void; // alias for disconnect()}Constructor parameters:
| Parameter | Type | Description |
|---|---|---|
vueRouter | Router | The Vue Router instance from createRouter() |
actor | RoutableActor | The XMachines actor with a currentRoute signal |
routeMap | RouteMap | The bidirectional map between the state IDs and the paths |
Methods:
connect()— starts the work in both directions. It first sets the actor state from the current path of the router, which supports a cold load and a direct URL. It useswatch(router.currentRoute, …)from@vue/reactivity, not from@vue/runtime-core. Therefore a watcher error goes to the caller, and the global error handler of Vue does not hide it.disconnect()— stops every watcher and stops the Vue effect scope.dispose()— the alias ofdisconnect(). Use it inonUnmounted(() => bridge.dispose()).
PlayRouterProvider
This Vue component wraps VueRouterBridge in the component lifecycle hooks.
import type { PlayActor } from "@xmachines/play-vue-router";
defineComponent({ name: "PlayRouterProvider", props: { actor: { type: Object as PropType<PlayActor>, required: true }, routeMap: { type: Object as PropType<RouteMap>, required: true }, router: { type: Object as PropType<Router>, required: true }, renderer: { type: Function as PropType<(actor: PlayActor, router: Router) => VNodeChild>, required: true, }, },});The actor prop requires a PlayActor (AbstractActor & Routable & Viewable), because the provider renders the current view spec and also keeps the routes in step. The renderer callback receives the same concrete actor type.
RouteMap / VueRouteMap
RouteMap comes from @xmachines/play-router. It is the bidirectional map between the state IDs and the paths, and the bridge uses it. VueRouteMap is a deprecated alias of RouteMap. The two names are identical, and the next major version removes the alias.
import { RouteMap } from "@xmachines/play-vue-router";
// Explicit constructionconst routeMap = new RouteMap([ { stateId: "home", path: "/" }, { stateId: "profile", path: "/profile/:userId" }, { stateId: "settings", path: "/settings/:section?" },]);import { createRouteMap } from "@xmachines/play-vue-router";
// Or derive from an XState machineconst routeMap = createRouteMap(machine); // machine: your routable machine (states carry meta.route)Exported error classes (@xmachines/play-vue-router/errors)
Every runtime error extends PlayError from @xmachines/play. The ./errors subpath exports them:
import { VueRouterCorrectionError, VueRouterNavigationError, VueRouterSendError,} from "@xmachines/play-vue-router/errors";| Class | Error code | When thrown |
|---|---|---|
VueRouterCorrectionError | PLAY_VUE_ROUTER_CORRECTION_FAILED | Deprecated. No code throws it. A correction reports VueRouterNavigationError |
VueRouterNavigationError | PLAY_VUE_ROUTER_NAV_FAILED | router.push() refused the navigation: a navigation guard stopped it, or a redirect replaced it |
VueRouterSendError | PLAY_VUE_ROUTER_SEND_FAILED | The Vue Router watcher callback cannot send play.route to the actor |
Each class holds the original Vue Router error in its cause property.
Exported types
// Bridge-level (routing only) — from @xmachines/play-router:export type { RouteMapping, PlayRouteEvent, RouterBridge, RoutableActor,} from "@xmachines/play-router";
// Provider-level (routing + view rendering) — PlayActor re-exported from @xmachines/play-router:export type { PlayActor } from "@xmachines/play-vue-router";// RoutableActor is also exported as a deprecated alias for PlayActorPlayActor is AbstractActor<AnyActorLogic> & Routable & Viewable. PlayRouterProvider requires this shape, because it renders the current view spec and also keeps the routes in step. Use RoutableActor from @xmachines/play-router when you need the routing alone, for example when you create a VueRouterBridge yourself.
Architecture
Sync directions
Router → Actor (watch(router.currentRoute, …)):
- The user navigates: a link click, the browser BACK button, or a
router.pushcall in the code. - Vue puts a new object in the
currentRouteshallowRef. watchfrom@vue/reactivityfires synchronously (scheduler: (job) => job()).- The bridge cleans the path, then finds the state ID in
routeMap. - The bridge sends
{ type: "play.route", to: "#stateId", params, query }to the actor.
Actor → Router (TC39 Signal watcher):
- The actor makes a transition, and the
actor.currentRoutesignal gets a new state ID or a new path. - The signal watcher fires in a microtask.
- The bridge resolves the navigation path with
resolveNavigationPath. - The bridge skips a parameterized pattern that has no concrete params, and
resolveNavigationPathreturnsnull. - The bridge calls
router.push(resolvedPath).
Echo suppression
The bridge sets lastSyncedPath before every router.push() call. The Vue watcher then fires with the same path, because the router repeats the push of the actor. The sanitizedPath === lastSyncedPath check stops the bridge before it sends an event.
Vue effect scope
The watch watcher runs inside its own effectScope(). disconnect() and dispose() call scope.stop(). This removes the watcher completely, and nothing stays in the global Vue effect scope.
Testing
# Run all tests for this packagepnpm --filter @xmachines/play-vue-router test
# Watch modepnpm --filter @xmachines/play-vue-router run test:watch
# With coverage (80 % threshold on lines/functions/branches/statements)pnpm --filter @xmachines/play-vue-router run test:coverageThe test files use Vitest with jsdom and @vue/test-utils. The integration tests (test/integration.test.ts) use a real Vue Router instance with an SFC fixture.