Skip to content

@xmachines/play-vue-router

API / @xmachines/play-vue-router

Vue Router 4/5 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.

License: MIT Version

Installation

Terminal window
pnpm add @xmachines/play-vue-router @xmachines/play-router @xmachines/core @xmachines/play @xmachines/play-atom

@xmachines/play-router names @xmachines/core, @xmachines/play and @xmachines/play-atom as peers, so the command installs them with this package.

Peer dependencies:

  • @xmachines/play — the core protocol. This package reads the error classes from its errors subpath.
  • vue ^3.5.0 — Vue runtime
  • @vue/reactivity ^3.5.0 — Vue reactivity primitives
  • vue-router ^4.0.0 || ^5.0.0 — Vue Router library

Usage

VueRouterBridge — low-level adapter

VueRouterBridge connects the currentRoute ref of Vue Router to the currentRoute atom 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, compose, PlayerActor } from "@xmachines/play-xstate";
import { withRouting } from "@xmachines/play-xstate/routing";
import type { AnyStateMachine } from "xstate";
declare const machine: AnyStateMachine; // 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 state
const RouteHost = { render: () => h("div") };
const router = createRouter({
history: createWebHistory(),
routes: [{ path: "/:pathMatch(.*)*", name: "xmachines-play", component: RouteHost }],
});
// 2. Create a bidirectional state ID ↔ path mapping
const 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 ready.
// The bridge needs the routing capability.
const actor = definePlayer({ machine, actor: compose(PlayerActor, withRouting) })();
actor.start();
await router.isReady();
const bridge = new VueRouterBridge(router, actor, routeMap);
bridge.connect();
// 4. Disconnect when tearing down (e.g. onUnmounted)
bridge.disconnect();

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, compose, PlayerActor } from "@xmachines/play-xstate";
import { withRouting } from "@xmachines/play-xstate/routing";
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 atom receivers.
// PlayRouterProvider needs the routing capability
const actor = markRaw(definePlayer({ machine, actor: compose(PlayerActor, withRouting) })());
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 atom of the actor.

import type { Router } from "vue-router";
import type { RoutableActor, RouteMap } from "@xmachines/play-vue-router";
declare class VueRouterBridge {
constructor(vueRouter: Router, actor: RoutableActor, routeMap: RouteMap);
connect(): void;
disconnect(): void;
}

Constructor parameters:

ParameterTypeDescription
vueRouterRouterThe Vue Router instance from createRouter()
actorRoutableActorThe XMachines actor with a currentRoute atom
routeMapRouteMapThe 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 uses watch(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.

PlayRouterProvider

This Vue component wraps VueRouterBridge in the component lifecycle hooks.

import type { RoutableActor } from "@xmachines/play-vue-router";
defineComponent({
name: "PlayRouterProvider",
props: {
actor: { type: Object as PropType<RoutableActor>, required: true },
routeMap: { type: Object as PropType<RouteMap>, required: true },
router: { type: Object as PropType<Router>, required: true },
renderer: {
type: Function as PropType<(actor: RoutableActor, router: Router) => VNodeChild>,
required: true,
},
},
});

The actor prop requires a RoutableActor, which is currentRoute, initialRoute and send. It names the view NOWHERE: routing implies no rendering. The renderer callback receives the concrete actor type of the caller, so a callback that reads currentView still reports the exact spec of that actor.

RouteMap

RouteMap comes from @xmachines/play-router. It is the bidirectional map between the state IDs and the paths, and the bridge uses it.

import { RouteMap } from "@xmachines/play-vue-router";
// Explicit construction
const routeMap = new RouteMap([
{ stateId: "home", path: "/" },
{ stateId: "profile", path: "/profile/:userId" },
{ stateId: "settings", path: "/settings/:section?" },
]);
import { createRouteMap } from "@xmachines/play-router/xstate";
import type { AnyStateMachine } from "xstate";
declare const machine: AnyStateMachine; // your routable machine (states carry meta.route)
// Or derive from an XState machine
const routeMap = createRouteMap(machine);

Exported error classes (@xmachines/play-vue-router/errors)

Every runtime error extends PlayError from @xmachines/play/errors. The ./errors subpath exports them:

import { VueRouterNavigationError, VueRouterSendError } from "@xmachines/play-vue-router/errors";
ClassError codeWhen thrown
VueRouterNavigationErrorPLAY_VUE_ROUTER_NAV_FAILEDrouter.push() refused the navigation: a navigation guard stopped it, or a redirect replaced it
VueRouterSendErrorPLAY_VUE_ROUTER_SEND_FAILEDThe 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

// Each type comes from @xmachines/play-router, and this package re-exports it:
import type {
RouteMapping,
RouteMapOptions,
PlayRouteEvent,
RouterBridge,
RoutableActor,
} from "@xmachines/play-vue-router";

RoutableActor is currentRoute, initialRoute and send, and it names no view and no engine. PlayRouterProvider constrains its actor to this shape, and a constraint keeps the concrete type whole — so a renderer callback that reads currentView still reports the exact spec of that actor.

Architecture

Sync directions

Router → Actor (watch(router.currentRoute, …)):

  1. The user navigates: a link click, the browser BACK button, or a router.push call in the code.
  2. Vue puts a new object in the currentRoute shallowRef.
  3. watch from @vue/reactivity fires synchronously (scheduler: (job) => job()).
  4. The bridge cleans the path, then finds the state ID in routeMap.
  5. The bridge sends { type: "play.route", to: "#stateId", params, query } to the actor.

Actor → Router (atom watcher):

  1. The actor makes a transition, and the actor.currentRoute atom gets a new state ID or a new path.
  2. The atom watcher fires in a microtask.
  3. The bridge resolves the navigation path with resolveNavigationPath.
  4. The bridge skips a parameterized pattern that has no concrete params, and resolveNavigationPath returns null.
  5. 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() calls scope.stop(). This removes the watcher completely, and nothing stays in the global Vue effect scope.

Modules