Skip to content

@xmachines/play-vue-router

API / @xmachines/play-vue-router

Vue Router 4.x adapter for XMachines Universal Player Architecture. Bidirectional sync between Vue Router and XMachines state machines using Vue’s reactive primitives.

Part of the xmachines-js monorepo.

Installation

Terminal window
npm install @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 wires Vue Router’s currentRoute ref to an XMachines actor’s currentRoute signal. Both directions are active: the actor drives the URL, and the URL drives the actor.

import { createRouter, createWebHistory } from "vue-router";
import { VueRouterBridge, RouteMap } from "@xmachines/play-vue-router";
// 1. Define routes
const router = createRouter({
history: createWebHistory(),
routes: [
{ path: "/", name: "home", component: HomePage },
{ path: "/profile/:userId", name: "profile", component: ProfilePage },
{ path: "/settings/:section?", name: "settings", component: SettingsPage },
],
});
// 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 bridge after the router is ready
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 is a convenience component that manages the bridge lifecycle automatically — it calls bridge.connect() after router.isReady() on mount and bridge.disconnect() on unmount.

<script setup lang="ts">
import { markRaw } from "vue";
import { useRouter } from "vue-router";
import { PlayRouterProvider, RouteMap } from "@xmachines/play-vue-router";
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(createActor());
</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";
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

Implements the RouterBridge protocol by watching Vue Router’s currentRoute shallowRef and the actor’s currentRoute TC39 Signal.

class VueRouterBridge {
constructor(vueRouter: Router, actor: RoutableActor, routeMap: RouteMap);
connect(): void;
disconnect(): void;
dispose(): void; // alias for disconnect()
}

Constructor parameters:

ParameterTypeDescription
vueRouterRouterVue Router instance from createRouter()
actorRoutableActorXMachines actor with a currentRoute signal
routeMapRouteMapBidirectional state ID ↔ path mapping

Methods:

  • connect() — Start bidirectional synchronization. Performs an initial sync from the router’s current path to the actor (cold-load / direct-URL support). Uses watch(router.currentRoute, …) from @vue/reactivity (not @vue/runtime-core) so watcher errors propagate directly without being swallowed by Vue’s global error handler.
  • disconnect() — Stop all watchers and stop the Vue effect scope.
  • dispose() — Alias for disconnect(), intended for onUnmounted(() => bridge.dispose()).

PlayRouterProvider

Vue component that wraps VueRouterBridge in 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 PlayActor (AbstractActor & Routable & Viewable) — the provider renders the current view spec in addition to synchronizing routes. The renderer callback receives the same concrete actor type.

RouteMap / VueRouteMap

RouteMap (re-exported from @xmachines/play-router) is the bidirectional state ID ↔ path mapping used by the bridge. VueRouteMap is an alias for RouteMap — both are identical.

import { RouteMap, createRouteMap } from "@xmachines/play-vue-router";
// Explicit construction
const routeMap = new RouteMap([
{ stateId: "home", path: "/" },
{ stateId: "profile", path: "/profile/:userId" },
{ stateId: "settings", path: "/settings/:section?" },
]);
// Or derive from an XState machine
import { createRouteMap } from "@xmachines/play-router";
const routeMap = createRouteMap(machine);

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

All runtime errors extend PlayError from @xmachines/play and are available from the ./errors subpath:

import {
VueRouterCorrectionError,
VueRouterNavigationError,
VueRouterSendError,
} from "@xmachines/play-vue-router/errors";
ClassError codeWhen thrown
VueRouterCorrectionErrorPLAY_VUE_ROUTER_CORRECTION_FAILEDrouter.replace() rejected when syncing actor → router (correction)
VueRouterNavigationErrorPLAY_VUE_ROUTER_NAV_FAILEDrouter.push() rejected (navigation guard cancellation, redirect)
VueRouterSendErrorPLAY_VUE_ROUTER_SEND_FAILEDVue Router watcher callback fails to deliver play.route to the actor

Each class carries a cause property with the original Vue Router error.

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 — the shape required by PlayRouterProvider, which renders the current view spec in addition to synchronizing routes. Use RoutableActor from @xmachines/play-router when only routing is needed (e.g. constructing VueRouterBridge directly).

Architecture

Sync directions

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

  1. User navigates (link click, browser back, programmatic router.push).
  2. Vue’s currentRoute shallowRef is assigned a new object.
  3. watch from @vue/reactivity fires synchronously (scheduler: (job) => job()).
  4. Bridge sanitizes the path, looks up the state ID in routeMap.
  5. Bridge sends { type: "play.route", to: "#stateId", params, query } to the actor.

Actor → Router (TC39 Signal watcher):

  1. Actor transitions; actor.currentRoute signal updates to a new state ID or path.
  2. Signal watcher fires in a microtask.
  3. Bridge resolves the navigation path via resolveNavigationPath.
  4. Parameterized patterns without concrete params are skipped (returns null).
  5. Bridge calls router.push(resolvedPath).

Echo suppression

lastSyncedPath is set before every router.push() call. When the Vue watcher subsequently fires with the same path (the router echoing the actor-initiated push), the sanitizedPath === lastSyncedPath check short-circuits before any event is sent.

Vue effect scope

The watch watcher runs inside a dedicated effectScope(). Calling disconnect() / dispose() calls scope.stop(), fully removing the watcher without leaking into the global Vue effect scope.

Testing

Terminal window
# Run all tests for this package
npm test -w packages/play-vue-router
# Watch mode
npm run test:watch -w packages/play-vue-router
# With coverage (80 % threshold on lines/functions/branches/statements)
npm run test:coverage -w packages/play-vue-router

Test files use Vitest with jsdom and @vue/test-utils. Integration tests (test/integration.test.ts) use real Vue Router instances with SFC fixtures.

Classes

Interfaces

Type Aliases

Variables

Functions

References

VueRouteMap

Renames and re-exports RouteMap