@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.currentRoutesignal - Signal-Only Reactivity (INV-05): TC39 watcher lifecycle + Solid reactive owner integration
Key Benefits:
- Bridge-first: Extends shared
RouterBridgeBasepolicy 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
npm install @solidjs/router@^0.13.0 solid-js@^1.8.0 @xmachines/play-solid-router @xmachines/play-solidPeer dependencies:
@solidjs/router^0.13.0 — SolidJS Router librarysolid-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 fromuseNavigate()hook (signals-aware navigation)location- Object fromuseLocation()hook (reactive pathname, search, hash)params- Object fromuseParams()hook (reactive route parameters)actor- XMachines actor instance (fromdefinePlayer().actor)routeMap- Bidirectional state ID ↔ path mapping
Methods:
connect()- Start bidirectional synchronization.disconnect()- Stop synchronization and cleanup bridge resources.dispose()- Alias ofdisconnect().
Internal Behavior:
- Uses
RouterBridgeBaseTC39 watcher lifecycle for actor→router synchronization - Updates SolidJS Router via
navigate(path)when actor state changes - Uses
createEffect(on(...))to watchlocation.pathnamesignal - Sends
play.routeevents 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 methodsclass RouteMap extends BaseRouteMap {}
// Inherited API:routeMap.getStateIdByPath(path: string): string | nullrouteMap.getPathByStateId(stateId: string): string | nullgetStateIdByPath 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 IDgetStateIdByPath(path)— Find state ID from path with pattern matching (supports:paramand: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"); // nullExamples
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 statesconst 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 setupfunction 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 routesimport { 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 routesfunction 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 handlingimport { 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 paramsfunction 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 guardsimport { 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.routeevent to actor - Actor’s
alwaysguard checksisAuthenticated - If
false, actor transitions tologinstate - 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:
createEffectsubscriptions 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):
- Actor transitions to new state with
meta.route actor.currentRoutesignal updatescreateEffect(on(...))fires with new route value- Bridge extracts state ID from signal
- Bridge looks up path via
routeMap.getPathByStateId(stateId) - Bridge calls
navigate(path) - SolidJS Router updates URL and renders component
Router → Actor (Location tracking via createEffect):
- User clicks link or browser back button
location.pathnamesignal updatescreateEffect(on(...))fires with new pathname- Bridge looks up state ID via
routeMap.getStateIdByPath(pathname) - Bridge extracts params from
useParams()reactive object - Bridge sends
play.routeevent to actor - Actor validates navigation (guards, transitions)
- If accepted: Actor transitions, signal updates, URL stays
- If rejected: Actor redirects, bridge corrects URL via
navigate()
Circular Update Prevention
Multi-layer guards prevent infinite loops:
lastSyncedPathtracking: Stores last synchronized path, skips if unchangedisProcessingNavigationflag: Set during navigation processing, prevents concurrent syncs- Effect timing: Solid’s batched updates and
defer: trueoption prevent rapid cycles
Signals-native pattern:
// Actor → RoutercreateEffect( on( () => this.actor.currentRoute.get(), (route) => { if (!route || route === this.lastSyncedPath || this.isProcessingNavigation) { return; } this.lastSyncedPath = route; this.navigate(route); }, { defer: true }, ),);
// Router → ActorcreateEffect( 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 viadefinePlayer()
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 componentconst navigate = useNavigate(); // ERROR: No reactive contextconst bridge = new SolidRouterBridge(navigate, ...);
// ✅ CORRECT: Bridge created inside componentfunction 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/:userIdmatches/profile/123):param?- Optional parameter (e.g.,/settings/:section?matches/settingsand/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.