Skip to content

@xmachines/play-solid-router

Documentation / @xmachines/play-solid-router

SolidJS Router adapter for XMachines Universal Player Architecture

SolidJS Router adapter using RouterBridgeBase for consistent actor↔router sync.

Overview

@xmachines/play-solid-router provides seamless integration between SolidJS Router and XMachines state machines. Built on Solid’s reactive primitives, it enables zero-adaptation signals synchronization.

Per RFC Play v1, this package implements:

  • Actor Authority (INV-01): State machine controls navigation, router reflects decisions
  • Passive Infrastructure (INV-04): Router observes actor.currentRoute signal
  • Signal-Only Reactivity (INV-05): TC39 watcher lifecycle + Solid reactive owner integration

Key Benefits:

  • Bridge-first: Extends shared RouterBridgeBase policy used by other adapters
  • Automatic tracking: Uses Solid reactivity for router→actor while base class handles actor→router watcher lifecycle
  • Fine-grained reactivity: Updates only affected components
  • Logic-driven navigation: Business logic in state machines, not components
  • Type-safe parameters: Route params flow through state machine context

Framework Compatibility:

  • SolidJS 1.8.4+ (signals-native architecture)
  • @solidjs/router 0.13.0+ (modern routing primitives)
  • TC39 Signals polyfill integration

Installation

Terminal window
npm install @solidjs/router@^0.13.0 solid-js@^1.8.0 @xmachines/play-solid-router @xmachines/play-solid

Peer dependencies:

  • @solidjs/router ^0.13.0 — SolidJS Router library
  • solid-js ^1.8.0 — SolidJS runtime
  • @xmachines/play-solid — Solid renderer (PlayRenderer)
  • @xmachines/play-actor — Actor base
  • @xmachines/play-router — Route extraction
  • @xmachines/play-signals — TC39 Signals primitives

Quick Start

import { Router, Route, useNavigate, useLocation, useParams } from '@solidjs/router';
import { onCleanup } from 'solid-js';
import { SolidRouterBridge, createRouteMap } from '@xmachines/play-solid-router';
import { definePlayer } from '@xmachines/play-xstate';
function App() {
// 1. Get SolidJS Router hooks (MUST be inside component)
const navigate = useNavigate();
const location = useLocation();
const params = useParams();
// 2. Create route mapping from machine routes
const routeMap = createRouteMap(authMachine);
// 3. Create player with state machine
const createPlayer = definePlayer({
machine: authMachine,
catalog: componentCatalog
});
const actor = createPlayer();
actor.start();
// 4. Create bridge to sync actor and router
const bridge = new SolidRouterBridge(
navigate,
location,
params,
actor,
routeMap
);
// 5. Start synchronization
bridge.connect();
// 6. Cleanup on component disposal
onCleanup(() => {
bridge.disconnect();
});
return (
<Router>
<Route path="/" component={HomeView} />
<Route path="/profile/:userId" component={ProfileView} />
<Route path="/settings/:section?" component={SettingsView} />
</Router>
);
}

API Reference

SolidRouterBridge

Router adapter implementing the RouterBridge protocol for SolidJS Router.

Type Signature:

class SolidRouterBridge {
constructor(
navigate: ReturnType<typeof useNavigate>,
location: ReturnType<typeof useLocation>,
params: ReturnType<typeof useParams>,
actor: AbstractActor<any>,
routeMap: RouteMap,
);
dispose(): void;
}

Constructor Parameters:

  • navigate - Function from useNavigate() hook (signals-aware navigation)
  • location - Object from useLocation() hook (reactive pathname, search, hash)
  • params - Object from useParams() hook (reactive route parameters)
  • actor - XMachines actor instance (from definePlayer().actor)
  • routeMap - Bidirectional state ID ↔ path mapping

Methods:

  • connect() - Start bidirectional synchronization.
  • disconnect() - Stop synchronization and cleanup bridge resources.
  • dispose() - Alias of disconnect().

Internal Behavior:

  • Uses RouterBridgeBase TC39 watcher lifecycle for actor→router synchronization
  • Updates SolidJS Router via navigate(path) when actor state changes
  • Uses createEffect(on(...)) to watch location.pathname signal
  • Sends play.route events to actor when user navigates
  • Prevents circular updates with path tracking and processing flags

RouteMap

Bidirectional mapping between XMachines state IDs and SolidJS Router paths with pattern matching support.

RouteMap extends BaseRouteMap from @xmachines/play-router, inheriting bucket-indexed bidirectional route matching. No routing logic lives in the adapter itself.

interface RouteMapping {
readonly stateId: string;
readonly path: string;
}
// RouteMap is a thin subclass of BaseRouteMap — no extra methods
class RouteMap extends BaseRouteMap {}
// Inherited API:
routeMap.getStateIdByPath(path: string): string | null
routeMap.getPathByStateId(stateId: string): string | null

getStateIdByPath and getPathByStateId both return null (not undefined) for misses.

Constructor Parameters:

  • mappings - Array of { stateId, path } entries:
    • stateId — State machine state ID (e.g., '#profile')
    • path — SolidJS Router path pattern (e.g., '/profile/:userId')

Methods:

  • getPathByStateId(stateId) — Find path pattern from state ID
  • getStateIdByPath(path) — Find state ID from path with pattern matching (supports :param and :param? syntax)

Pattern Matching:

Uses bucket-indexed RegExp matching for dynamic routes:

const routeMap = new RouteMap([{ stateId: "#settings", path: "/settings/:section?" }]);
routeMap.getStateIdByPath("/settings"); // '#settings'
routeMap.getStateIdByPath("/settings/account"); // '#settings'
routeMap.getStateIdByPath("/settings/privacy"); // '#settings'
routeMap.getStateIdByPath("/other"); // null

Examples

Basic Usage: Simple 2-3 Route Setup

import { Router, Route } from '@solidjs/router';
import { createSignal } from 'solid-js';
import { defineCatalog } from '@xmachines/play-catalog';
// State machine with 3 states
const appMachine = setup({
types: {
events: {} as PlayRouteEvent
}
}).createMachine({
id: 'app',
initial: 'home',
states: {
home: {
meta: { route: '/', view: { component: 'Home' } }
},
about: {
meta: { route: '/about', view: { component: 'About' } }
},
contact: {
meta: { route: '/contact', view: { component: 'Contact' } }
}
}
});
const catalog = defineCatalog({
Home,
About,
Contact,
});
// Component setup
function App() {
const navigate = useNavigate();
const location = useLocation();
const params = useParams();
const routeMap = createRouteMap(appMachine);
const createPlayer = definePlayer({ machine: appMachine, catalog });
const actor = createPlayer();
actor.start();
const bridge = new SolidRouterBridge(navigate, location, params, actor, routeMap);
onCleanup(() => bridge.dispose());
return (
<Router>
<Route path="/" component={Home} />
<Route path="/about" component={About} />
<Route path="/contact" component={Contact} />
</Router>
);
}

Parameter Handling: Dynamic Routes with :param Syntax

// State machine with parameter routes
import { formatPlayRouteTransitions } from "@xmachines/play-xstate";
import { defineCatalog } from "@xmachines/play-catalog";
const machineConfig = {
id: 'app',
context: {},
states: {
profile: {
meta: {
route: '/profile/:userId',
view: { component: 'Profile' },
},
},
settings: {
meta: {
route: '/settings/:section?',
view: { component: 'Settings' },
},
}
}
};
const appMachine = setup({
types: {
context: {} as { userId?: string; section?: string },
events: {} as PlayRouteEvent
}
}).createMachine(formatPlayRouteTransitions(machineConfig));
const catalog = defineCatalog({
Profile,
Settings,
});
// Router with dynamic routes
function App() {
const navigate = useNavigate();
const location = useLocation();
const params = useParams();
const routeMap = createRouteMap(appMachine);
const createPlayer = definePlayer({ machine: appMachine, catalog });
const actor = createPlayer();
actor.start();
const bridge = new SolidRouterBridge(navigate, location, params, actor, routeMap);
onCleanup(() => bridge.dispose());
return (
<Router>
<Route path="/profile/:userId" component={Profile} />
<Route path="/settings/:section?" component={Settings} />
</Router>
);
}

Usage in component:

function ProfileButton(props: { userId: string }) {
return (
<button
onClick={() =>
props.actor.send({
type: "play.route",
to: "#profile",
params: { userId: props.userId },
})
}
>
View Profile
</button>
);
}

Query Parameters: Search/Filters via Query Strings

// State machine with query param handling
import { formatPlayRouteTransitions } from "@xmachines/play-xstate";
import { defineCatalog } from "@xmachines/play-catalog";
const machineConfig = {
context: { query: '', filters: {} },
states: {
search: {
meta: {
route: '/search',
view: { component: 'Search' },
},
}
}
};
const searchMachine = setup({
types: {
context: {} as { query: string; filters: Record<string, string> },
events: {} as PlayRouteEvent
}
}).createMachine(formatPlayRouteTransitions(machineConfig));
const catalog = defineCatalog({
Search,
});
const player = definePlayer({ machine: searchMachine, catalog });
// Component sends query params
function SearchBar(props) {
const [searchTerm, setSearchTerm] = createSignal('');
function handleSearch() {
props.actor.send({
type: 'play.route',
to: '#search',
query: { q: searchTerm(), tag: 'typescript' }
});
}
return (
<div>
<input
value={searchTerm()}
onInput={(e) => setSearchTerm(e.target.value)}
/>
<button onClick={handleSearch}>Search</button>
</div>
);
}

SolidJS Router automatically reflects query params in URL:

  • /search?q=xmachines&tag=typescript

Protected Routes: Authentication Guards

// State machine with auth guards
import { defineCatalog } from "@xmachines/play-catalog";
const authMachine = setup({
types: {
context: {} as { isAuthenticated: boolean },
events: {} as PlayRouteEvent | { type: "login" } | { type: "logout" },
},
}).createMachine({
context: { isAuthenticated: false },
initial: "home",
states: {
home: {
meta: { route: "/", view: { component: "Home" } },
},
login: {
meta: { route: "/login", view: { component: "Login" } },
on: {
login: {
target: "dashboard",
actions: assign({ isAuthenticated: true }),
},
},
},
dashboard: {
meta: { route: "/dashboard", view: { component: "Dashboard" } },
always: {
guard: ({ context }) => !context.isAuthenticated,
target: "login",
},
},
},
});
const catalog = defineCatalog({
Home,
Login,
Dashboard,
});
const player = definePlayer({ machine: authMachine, catalog });

Guard behavior:

  • User navigates to /dashboard
  • Bridge sends play.route event to actor
  • Actor’s always guard checks isAuthenticated
  • If false, actor transitions to login state
  • Bridge detects state change via createEffect, redirects to /login
  • Actor Authority principle enforced

Cleanup: Proper Disposal on Component Unmount

import { onCleanup } from "solid-js";
import { SolidRouterBridge } from "@xmachines/play-solid-router";
function App() {
const navigate = useNavigate();
const location = useLocation();
const params = useParams();
const actor = useContext(ActorContext);
const routeMap = useContext(RouteMapContext);
const bridge = new SolidRouterBridge(navigate, location, params, actor, routeMap);
// CRITICAL: Cleanup effects
onCleanup(() => {
bridge.dispose();
});
return <Router>...</Router>;
}

Why cleanup matters:

  • createEffect subscriptions continue after disposal (memory leak)
  • Multiple bridge instances send duplicate events
  • Tests fail with “Cannot send to stopped actor” errors
  • Solid’s fine-grained reactivity tracks disposed components

Architecture

Bidirectional Sync (Actor ↔ Router)

Actor → Router (Signal-driven via createEffect):

  1. Actor transitions to new state with meta.route
  2. actor.currentRoute signal updates
  3. createEffect(on(...)) fires with new route value
  4. Bridge extracts state ID from signal
  5. Bridge looks up path via routeMap.getPathByStateId(stateId)
  6. Bridge calls navigate(path)
  7. SolidJS Router updates URL and renders component

Router → Actor (Location tracking via createEffect):

  1. User clicks link or browser back button
  2. location.pathname signal updates
  3. createEffect(on(...)) fires with new pathname
  4. Bridge looks up state ID via routeMap.getStateIdByPath(pathname)
  5. Bridge extracts params from useParams() reactive object
  6. Bridge sends play.route event to actor
  7. Actor validates navigation (guards, transitions)
  8. If accepted: Actor transitions, signal updates, URL stays
  9. If rejected: Actor redirects, bridge corrects URL via navigate()

Circular Update Prevention

Multi-layer guards prevent infinite loops:

  1. lastSyncedPath tracking: Stores last synchronized path, skips if unchanged
  2. isProcessingNavigation flag: Set during navigation processing, prevents concurrent syncs
  3. Effect timing: Solid’s batched updates and defer: true option prevent rapid cycles

Signals-native pattern:

// Actor → Router
createEffect(
on(
() => this.actor.currentRoute.get(),
(route) => {
if (!route || route === this.lastSyncedPath || this.isProcessingNavigation) {
return;
}
this.lastSyncedPath = route;
this.navigate(route);
},
{ defer: true },
),
);
// Router → Actor
createEffect(
on(
() => this.location.pathname,
(pathname) => {
if (pathname === this.lastSyncedPath || this.isProcessingNavigation) {
return;
}
this.isProcessingNavigation = true;
this.actor.send({ type: "play.route", to: stateId, params });
this.isProcessingNavigation = false;
},
{ defer: true },
),
);

Relationship to Other Packages

Package Dependencies:

  • @xmachines/play - Protocol interfaces (PlayRouteEvent, RouterBridge)
  • @xmachines/play-actor - Actor base class with signal protocol
  • @xmachines/play-router - Route extraction and pattern matching
  • @xmachines/play-signals - TC39 Signals polyfill for reactivity
  • @xmachines/play-xstate - XState integration via definePlayer()

Architecture Layers:

┌─────────────────────────────────────┐
│ Solid Components (View Layer) │
│ - Props include actor reference │
│ - Sends play.route events │
└─────────────────────────────────────┘
┌─────────────────────────────────────┐
│ SolidRouterBridge (Adapter) │
│ - createEffect(actor.currentRoute) │
│ - createEffect(location.pathname) │
└─────────────────────────────────────┘
↕ ↕
┌─────────────┐ ┌──────────────────┐
│ SolidJS │ │ XMachines Actor │
│ Router │ │ (Business Logic) │
│ (Infra) │ │ │
└─────────────┘ └──────────────────┘

Signals Integration (SolidJS-Specific)

Why signals-native matters:

  • Zero adaptation: Solid signals and TC39 Signals share reactive primitives
  • Automatic tracking: createEffect(on(...)) tracks dependencies without manual Watcher setup
  • Fine-grained updates: Only affected components re-render (not full tree)
  • Batched updates: Solid batches multiple signal changes in single render cycle

Hook context requirement (Pitfall 2):

SolidJS hooks (useNavigate, useLocation, useParams) MUST be called inside component tree:

// ❌ WRONG: Bridge created outside component
const navigate = useNavigate(); // ERROR: No reactive context
const bridge = new SolidRouterBridge(navigate, ...);
// ✅ CORRECT: Bridge created inside component
function App() {
const navigate = useNavigate();
const location = useLocation();
const params = useParams();
const bridge = new SolidRouterBridge(navigate, location, params, actor, routeMap);
onCleanup(() => bridge.dispose());
return <Router>...</Router>;
}

Why: Solid’s reactivity system requires reactive ownership context. Hooks create tracked scopes that exist only within component lifecycle.

Pattern Matching for Dynamic Routes

Bucket-indexed matching via BaseRouteMap:

Routes are grouped by their first path segment into buckets. On each lookup only the relevant bucket (plus the wildcard * bucket for :param-first routes) is scanned — typically far fewer than all registered routes.

Supported syntax:

  • :param - Required parameter (e.g., /profile/:userId matches /profile/123)
  • :param? - Optional parameter (e.g., /settings/:section? matches /settings and /settings/account)
  • Wildcards via * (future enhancement)

Example:

const routeMap = new RouteMap([
{ stateId: "#profile", path: "/profile/:userId" },
{ stateId: "#settings", path: "/settings/:section?" },
]);
routeMap.getStateIdByPath("/profile/123"); // '#profile'
routeMap.getStateIdByPath("/settings"); // '#settings'
routeMap.getStateIdByPath("/settings/privacy"); // '#settings'

License

Copyright (c) 2016 Mikael Karon. All rights reserved.

This work is licensed under the terms of the MIT license.
For a copy, see https://opensource.org/licenses/MIT.

Classes

Interfaces

Type Aliases

Functions