Skip to content

@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.

License: MIT Version

Installation

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

Peer dependencies:

  • 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
  • xstate ^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 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
const 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:

ParameterTypeDescription
vueRouterRouterThe Vue Router instance from createRouter()
actorRoutableActorThe XMachines actor with a currentRoute signal
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.
  • dispose() — the alias of disconnect(). Use it in onUnmounted(() => 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 construction
const 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 machine
const 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";
ClassError codeWhen thrown
VueRouterCorrectionErrorPLAY_VUE_ROUTER_CORRECTION_FAILEDDeprecated. No code throws it. A correction reports VueRouterNavigationError
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

// 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 PlayActor

PlayActor 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, …)):

  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 (TC39 Signal watcher):

  1. The actor makes a transition, and the actor.currentRoute signal gets a new state ID or a new path.
  2. The signal 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() and dispose() call scope.stop(). This removes the watcher completely, and nothing stays in the global Vue effect scope.

Testing

Terminal window
# Run all tests for this package
pnpm --filter @xmachines/play-vue-router test
# Watch mode
pnpm --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:coverage

The 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.

Classes

Interfaces

Type Aliases

Variables

Functions