@xmachines/play-xstate 1.0.0-beta.45 → 1.0.0-beta.48

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (2) hide show
  1. package/README.md +167 -496
  2. package/package.json +10 -10
package/README.md CHANGED
@@ -1,623 +1,294 @@
1
- # @xmachines/play-xstate
2
-
3
- **XState v5 adapter for Play Architecture with signal-driven reactivity and routing**
4
-
5
- Transform declarative state machines into live actors with TC39 Signals and parameter-aware navigation.
1
+ <!-- generated-by: gsd-doc-writer -->
6
2
 
7
- ## Overview
3
+ # @xmachines/play-xstate
8
4
 
9
- `@xmachines/play-xstate` provides `definePlayer()`, the primary API for binding XState v5 state machines to the Play Architecture actor base. It enables business logic to control routing and state through guard-enforced transitions, signal lifecycle management, and XState DevTools compatibility.
5
+ > XState v5 adapter for the XMachines Play Architecture bind state machines to the actor base with signal-driven reactivity and router integration.
10
6
 
11
- Per [Play RFC](../docs/rfc/play.md), this package implements:
7
+ [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT)
8
+ [![Version](https://img.shields.io/badge/version-1.0.0--beta.46-blue)](https://www.npmjs.com/package/@xmachines/play-xstate)
12
9
 
13
- - **Actor Authority (INV-01):** State machine guards decide navigation validity
14
- - **Strict Separation (INV-02):** Zero React/framework imports in business logic
15
- - **Signal-Only Reactivity (INV-05):** TC39 Signals expose all state changes
10
+ Part of the [XMachines Play](../../README.md) monorepo.
16
11
 
17
- **Routing:** Supports `meta.route` patterns, `play.route` events with parameters, and route extraction.
12
+ ---
18
13
 
19
14
  ## Installation
20
15
 
21
16
  ```bash
22
- npm install xstate@^5.0.0
23
- npm install @xmachines/play-xstate
17
+ npm install @xmachines/play-xstate xstate
24
18
  ```
25
19
 
26
- **Peer dependencies:**
20
+ `xstate ^5.30.0` is a peer dependency and must be installed alongside this package.
27
21
 
28
- - `xstate` ^5.0.0 — State machine runtime
22
+ ---
29
23
 
30
24
  ## Quick Start
31
25
 
32
26
  ```typescript
33
- import { setup, assign } from "xstate";
34
- import { definePlayer, formatPlayRouteTransitions } from "@xmachines/play-xstate";
27
+ import { setup } from "xstate";
28
+ import { definePlayer } from "@xmachines/play-xstate";
35
29
 
36
- // 1. Define XState machine with meta.route and view spec
37
- const machine = setup({
38
- types: {
39
- context: {} as {
40
- isAuthenticated: boolean;
41
- params: Record<string, string>;
42
- query: Record<string, string>;
43
- },
44
- events: {} as
45
- | { type: "play.route"; to: string; params?: Record<string, string> }
46
- | { type: "auth.login" }
47
- | { type: "auth.logout" },
30
+ // 1. Define your XState v5 machine
31
+ const machine = setup({}).createMachine({
32
+ initial: "idle",
33
+ states: {
34
+ idle: { meta: { route: "/" } },
35
+ active: { meta: { route: "/active" } },
48
36
  },
49
- }).createMachine(
50
- formatPlayRouteTransitions({
51
- id: "app",
52
- initial: "login",
53
- context: { isAuthenticated: false, params: {}, query: {} },
54
- states: {
55
- login: {
56
- id: "login",
57
- meta: {
58
- route: "/login",
59
- view: {
60
- component: "Login",
61
- spec: {
62
- root: "root",
63
- elements: {
64
- root: { type: "Login", props: { title: "Sign In" }, children: [] },
65
- },
66
- },
67
- },
68
- },
69
- },
70
- dashboard: {
71
- id: "dashboard",
72
- meta: {
73
- route: "/dashboard",
74
- view: {
75
- component: "Dashboard",
76
- spec: {
77
- root: "root",
78
- elements: {
79
- root: { type: "Dashboard", props: {}, children: [] },
80
- },
81
- },
82
- },
83
- },
84
- always: [{ target: "login", guard: ({ context }) => !context.isAuthenticated }],
85
- },
86
- },
87
- on: {
88
- "auth.login": {
89
- target: ".dashboard",
90
- guard: ({ context }) => !context.isAuthenticated,
91
- actions: assign({ isAuthenticated: true }),
92
- },
93
- "auth.logout": {
94
- target: ".login",
95
- actions: assign({ isAuthenticated: false }),
96
- },
97
- },
98
- }),
99
- );
37
+ });
100
38
 
101
- // 2. Create player factory
39
+ // 2. Create a player factory
102
40
  const createPlayer = definePlayer({ machine });
103
41
 
104
- // 3. Create and start actor
42
+ // 3. Instantiate and start an actor
105
43
  const actor = createPlayer();
106
44
  actor.start();
107
45
 
108
- // 4. Observe signals
109
- console.log(actor.currentRoute.get()); // "/login"
110
- console.log(actor.currentView.get()); // { component: "Login", spec: {...} }
46
+ // 4. Observe TC39 Signal-based reactive state
47
+ console.log(actor.currentRoute.get()); // "/"
48
+ console.log(actor.state.get().value); // "idle"
111
49
 
112
- // 5. Cleanup
113
- actor.dispose();
114
- ```
50
+ // 5. Send events — machine guards decide transitions
51
+ actor.send({ type: "activate" });
115
52
 
116
- ## API Reference
117
-
118
- ### definePlayer()
119
-
120
- Create a player factory from an XState machine:
121
-
122
- ```typescript
123
- const createPlayer = definePlayer<TMachine>({
124
- machine: AnyStateMachine,
125
- options?: PlayerOptions,
126
- }): PlayerFactory;
53
+ actor.stop();
127
54
  ```
128
55
 
129
- **Config:**
56
+ ---
130
57
 
131
- - `machine` (required) - XState v5 state machine
132
- - `options` (optional) - Lifecycle hooks
58
+ ## API Summary
133
59
 
134
- **Returns:** Factory function `(input?, { snapshot }?) => PlayerActor`
60
+ ### `definePlayer(config)`
135
61
 
136
- **Example:**
62
+ Creates a `PlayerFactory` from an XState v5 machine. The factory pattern enables multiple independent actor instances from a single configuration — useful for multi-user scenarios, SSR, or testing.
137
63
 
138
64
  ```typescript
65
+ import { setup } from "xstate";
66
+ import { definePlayer } from "@xmachines/play-xstate";
67
+
68
+ const machine = setup({
69
+ types: {
70
+ context: {} as { userId: string },
71
+ input: {} as { userId: string },
72
+ },
73
+ }).createMachine({
74
+ context: ({ input }) => ({ userId: input.userId }),
75
+ initial: "home",
76
+ states: { home: {} },
77
+ });
78
+
139
79
  const createPlayer = definePlayer({
140
- machine: authMachine,
80
+ machine,
141
81
  options: {
142
- onStart: (actor) => console.log("Started:", actor.id),
143
- onTransition: (actor, prev, next) => {
144
- console.log("Transition:", prev.value, "", next.value);
145
- },
82
+ onStart: (actor) => console.log("started"),
83
+ onStop: (actor) => console.log("stopped"),
84
+ onTransition: (actor, prev, next) => console.log("transitioned"),
85
+ onStateChange: (actor, state) => console.log("state changed"),
86
+ onError: (actor, err) => console.error(err),
146
87
  },
147
88
  });
148
89
 
149
- const actor1 = createPlayer({ userId: "user1" });
150
- const actor2 = createPlayer({ userId: "user2" });
151
- // Multiple independent actor instances
90
+ // Each call returns an independent PlayerActor instance
91
+ const alice = createPlayer({ userId: "alice" });
92
+ const bob = createPlayer({ userId: "bob" });
152
93
  ```
153
94
 
154
- **Restoring from a snapshot:**
95
+ #### `PlayerFactory` signature
155
96
 
156
97
  ```typescript
157
- const createPlayer = definePlayer({ machine });
98
+ type PlayerFactory<TMachine> = (
99
+ input?: InputFrom<TMachine>,
100
+ options?: PlayerFactoryResumeOptions<TMachine>,
101
+ ) => PlayerActor<TMachine>;
102
+ ```
158
103
 
159
- const actor = createPlayer();
160
- actor.start();
104
+ #### Restoring from a snapshot
161
105
 
162
- // ...persist the actor snapshot however your app stores it
106
+ ```typescript
163
107
  const snapshot = actor.getSnapshot();
164
108
  actor.stop();
165
109
 
166
- const restoredActor = createPlayer(undefined, { snapshot });
167
- restoredActor.start();
110
+ // Restore to the exact saved state
111
+ const restored = createPlayer({ userId: "alice" }, { snapshot });
112
+ restored.start();
113
+ console.log(restored.currentRoute.get()); // same route as when saved
168
114
  ```
169
115
 
170
- `restoredActor.initialRoute` still reflects the machine's default initial route, not the
171
- restored snapshot route. Router bridges rely on that invariant to distinguish a deep-link
172
- from a restored session during `connect()`.
173
-
174
- ### PlayerActor
175
-
176
- Concrete actor implementing Play signal protocol:
177
-
178
- **Signal Properties:**
179
-
180
- - `state: Signal.State<AnyMachineSnapshot>` — Reactive snapshot of current state
181
- - `currentRoute: Signal.Computed<string | null>` — Derived URL from the current state's `meta.route` and `context.params`. Returns `null` when no route metadata is present or a required route parameter is missing from context.
182
- - `currentView: Signal.State<ViewMetadata | null>` — Current view spec (updated on every state transition). The spec is automatically enriched with `context.params` (URL params) and any `contextProps`-allowlisted context fields before being emitted (see [Prop Enrichment from Routing and Context](#prop-enrichment-from-routing-and-context)).
116
+ ---
183
117
 
184
- **Lifecycle ordering:**
118
+ ### `PlayerActor<TMachine>`
185
119
 
186
- - `state` and `currentRoute` update synchronously from the active XState snapshot.
187
- - `currentView` is then validated and cached from that same snapshot.
188
- - `onStateChange` runs after those signals are current.
189
- - `onTransition` runs after `send()` completes, with both previous and next snapshots.
190
- - `onStart` runs after the initial active snapshot has been published.
120
+ Concrete actor class that wraps an XState v5 actor and exposes TC39 Signal-based reactive signals. Implements both `Routable` and `Viewable` interfaces from `@xmachines/play-actor`.
191
121
 
192
- **Methods:**
122
+ #### Signals
193
123
 
194
- - `start()` Start the actor (must call after creation)
195
- - `stop()` Stop the actor
196
- - `send(event)` Send event to actor. Typed as `EventFromLogic<TMachine>` when `TMachine` is specified.
197
- - `can(event)` Returns `true` if the current state can accept the given event. Typed as `EventFromLogic<TMachine>` passing an unknown event type is a compile error. Reads from the `state` signal (no allocation). Use instead of `getSnapshot().can()`.
198
- - `dispose()` Convenience cleanup (calls `stop()`)
199
- - `getSnapshot()` Get current XState snapshot, typed as `SnapshotFrom<TMachine>`
124
+ | Signal | Type | Description |
125
+ | -------------- | -------------------------------------- | ---------------------------------------------------------------------------------------------------------------- |
126
+ | `state` | `Signal.State<SnapshotFrom<TMachine>>` | Current XState snapshot; updated on every active transition |
127
+ | `currentRoute` | `Signal.Computed<string \| null>` | Derived URL from active state's `meta.route` template and context |
128
+ | `currentView` | `Signal.State<PlaySpec \| null>` | View spec from active state's `meta.view` metadata; enriched with context params |
129
+ | `initialRoute` | `readonly string \| null` | Machine's initial-state route (fixed at construction; used by router bridges for deep-link vs restore detection) |
200
130
 
201
- **Routing failure behavior:**
131
+ #### Methods
202
132
 
203
- - `currentRoute` returns `null` instead of throwing when a required route parameter (e.g. `:username`) is absent from context. This handles transient states during navigation without crashing signal watchers.
204
- - Errors from `buildRouteUrl()` are typed as `MissingRouteParamError` (importable from `@xmachines/play-xstate/errors`).
133
+ | Method | Description |
134
+ | --------------- | -------------------------------------------------------------- |
135
+ | `start()` | Start the actor and fire `onStart` hook |
136
+ | `stop()` | Stop the actor, clean up subscriptions, fire `onStop` hook |
137
+ | `send(event)` | Send a typed event to the machine; fires `onTransition` hook |
138
+ | `can(event)` | Returns `true` if the current state can accept the given event |
139
+ | `getSnapshot()` | Returns the current XState snapshot |
140
+ | `dispose()` | Alias for `stop()` |
205
141
 
206
- **View derivation and route-param enrichment:**
207
-
208
- - `currentView` emits a fresh `ViewMetadata` object on every transition so TC39 Signal equality checks always detect changes (including re-entries to the same state with different params).
209
- - `context.params` and `contextProps`-allowlisted context fields are merged into spec element props — see [Prop Enrichment from Routing and Context](#prop-enrichment-from-routing-and-context).
210
-
211
- **Example:**
142
+ #### Signal usage example
212
143
 
213
144
  ```typescript
214
- const actor = createPlayer();
215
- actor.start();
145
+ import { Signal } from "@xmachines/play-signals";
216
146
 
217
- // Observe signals with watcher
218
147
  const watcher = new Signal.subtle.Watcher(() => {
219
148
  queueMicrotask(() => {
220
- const route = actor.currentRoute.get();
221
- console.log("Route changed:", route);
149
+ console.log("Route changed:", actor.currentRoute.get());
222
150
  });
223
151
  });
152
+
224
153
  watcher.watch(actor.currentRoute);
225
- actor.currentRoute.get(); // Initial read
154
+ actor.start();
226
155
  ```
227
156
 
228
- ### Guard Composition
157
+ ---
158
+
159
+ ### Guard utilities
160
+
161
+ Composable guard helpers that wrap XState's built-in `and()`, `or()`, and `not()` for use in machine `setup({ guards })` definitions.
229
162
 
230
163
  ```typescript
164
+ import { setup } from "xstate";
231
165
  import {
232
- composeGuards,
233
- composeGuardsOr,
234
- negateGuard,
235
- hasContext,
236
- eventMatches,
237
- contextFieldMatches,
166
+ composeGuards, // AND logic: all guards must pass
167
+ composeGuardsOr, // OR logic: at least one guard must pass
168
+ negateGuard, // NOT logic: inverts a guard
169
+ hasContext, // guard: context field is present and non-null
170
+ eventMatches, // guard: event type matches a string
171
+ contextFieldMatches, // guard: context field equals a value
238
172
  } from "@xmachines/play-xstate";
239
173
 
240
174
  const machine = setup({
241
175
  guards: {
242
- isLoggedIn: hasContext("userId"),
243
- isAdmin: ({ context }) => context.role === "admin",
176
+ isLoggedIn: ({ context }) => !!context.userId,
177
+ hasAdminRole: ({ context }) => context.role === "admin",
244
178
  },
245
179
  }).createMachine({
246
180
  on: {
247
181
  accessAdmin: {
248
- // Array means AND - all guards must pass
249
- guard: composeGuards(["isLoggedIn", "isAdmin"]),
182
+ guard: composeGuards(["isLoggedIn", "hasAdminRole"]),
250
183
  target: "adminPanel",
251
184
  },
252
- accessPublic: {
253
- // OR composition - any guard passes
254
- guard: composeGuardsOr(["isLoggedIn", ({ event }) => event.type === "guest.access"]),
255
- target: "publicArea",
256
- },
257
- logout: {
258
- // NOT composition
259
- guard: negateGuard("isLoggedIn"),
260
- target: "login",
185
+ accessDashboard: {
186
+ guard: negateGuard("isGuest"),
187
+ target: "dashboard",
261
188
  },
262
189
  },
190
+ // ...
263
191
  });
264
192
  ```
265
193
 
266
- **Helpers:**
194
+ ---
267
195
 
268
- - `hasContext(path: string)` - Check if context property is truthy
269
- - `eventMatches(type: string)` - Check event type
270
- - `contextFieldMatches(fieldPath: string, expectedValue: unknown)` - Check context field with strict structural equality
271
- - `composeGuards(guards: Array)` - AND composition
272
- - `composeGuardsOr(guards: Array)` - OR composition
273
- - `negateGuard(guard)` - NOT composition
196
+ ### Routing utilities
274
197
 
275
- ## Examples
198
+ Helper functions for declarative route configuration in XState machines.
276
199
 
277
- ### Guard Placement Philosophy
200
+ #### `formatPlayRouteTransitions(machineConfig)`
278
201
 
279
- **Guards check if you can BE in a state (state entry), not if you can TAKE an event (event handlers).**
202
+ Crawls machine states with `meta.route` and auto-generates `play.route` event handlers at the root level eliminating boilerplate routing transitions.
280
203
 
281
204
  ```typescript
282
205
  import { setup } from "xstate";
283
- import { definePlayer, formatPlayRouteTransitions } from "@xmachines/play-xstate";
206
+ import { formatPlayRouteTransitions } from "@xmachines/play-xstate";
284
207
 
285
- // Pattern 1: RECOMMENDED - Use formatPlayRouteTransitions utility
286
- const machineConfig = {
208
+ const config = formatPlayRouteTransitions({
287
209
  id: "app",
288
- initial: "home",
289
- context: { isAuthenticated: false },
290
210
  states: {
291
211
  home: {
292
212
  id: "home",
293
- meta: { route: "/", view: { component: "Home" } },
294
- },
295
- dashboard: {
296
- id: "dashboard",
297
- meta: { route: "/dashboard", view: { component: "Dashboard" } },
298
- // Always-guard validates state entry
299
- always: [
300
- {
301
- target: "login",
302
- guard: ({ context }) => !context.isAuthenticated,
303
- },
304
- ],
305
- },
306
- login: {
307
- id: "login",
308
- meta: { route: "/login", view: { component: "Login" } },
309
- },
310
- },
311
- };
312
-
313
- // formatPlayRouteTransitions handles routing infrastructure
314
- const machine = setup({
315
- types: {
316
- context: {} as {
317
- isAuthenticated: boolean;
318
- params: Record<string, string>;
319
- query: Record<string, string>;
320
- },
321
- events: {} as
322
- | { type: "play.route"; to: string; params?: Record<string, string> }
323
- | { type: "auth.login" },
324
- },
325
- }).createMachine(formatPlayRouteTransitions(machineConfig));
326
-
327
- const createPlayer = definePlayer({ machine });
328
- const actor = createPlayer();
329
- actor.start();
330
-
331
- // Navigation via play.route event
332
- actor.send({ type: "play.route", to: "/dashboard" });
333
- // Guard validates: Can I BE in dashboard state?
334
- // If !isAuthenticated → redirects to login
335
- ```
336
-
337
- **Why this works:**
338
-
339
- - `formatPlayRouteTransitions` adds routing infrastructure (event.to → state mapping)
340
- - Always-guards handle business logic (authentication checks)
341
- - Clear separation: routing is infrastructure, guards are business logic
342
-
343
- **Anti-pattern (DON'T DO THIS):**
344
-
345
- ```typescript
346
- // ❌ WRONG - Guard on event checking event properties
347
- on: {
348
- "play.route": {
349
- guard: ({ event }) => event.to === "/dashboard",
350
- target: "dashboard"
351
- }
352
- }
353
- ```
354
-
355
- **Reference:** See `docs/examples/routing-patterns.md` for canonical `formatPlayRouteTransitions` usage with always-guards for authentication.
356
-
357
- ### Lifecycle Hooks
358
-
359
- ```typescript
360
- const createPlayer = definePlayer({
361
- machine,
362
- options: {
363
- onStart: (actor) => {
364
- console.log("Actor started:", actor.id);
365
- },
366
- onStop: (actor) => {
367
- console.log("Actor stopped:", actor.id);
368
- },
369
- onTransition: (actor, prev, next) => {
370
- console.log("State change:", {
371
- from: prev.value,
372
- to: next.value,
373
- timestamp: Date.now(),
374
- });
213
+ meta: { route: "/home" },
375
214
  },
376
- onStateChange: (actor, state) => {
377
- // Called on every state update
378
- console.log("Snapshot updated:", state.value);
379
- },
380
- onError: (actor, error) => {
381
- console.error("Actor error:", error);
382
- // Log to monitoring service, show error UI, etc.
215
+ profile: {
216
+ id: "profile",
217
+ meta: { route: "/users/:userId" },
383
218
  },
384
219
  },
385
220
  });
386
- ```
387
-
388
- ### XState DevTools Integration
389
-
390
- ```typescript
391
- import { createBrowserInspector } from "@statelyai/inspect";
392
- import { definePlayer } from "@xmachines/play-xstate";
393
221
 
394
- const { inspect } = createBrowserInspector();
395
-
396
- const createPlayer = definePlayer({ machine });
397
- const actor = createPlayer();
398
- actor.start();
399
-
400
- // PlayerActor maintains XState Inspector compatibility
401
- // Inspector displays:
402
- // - State transitions and values
403
- // - Context data
404
- // - Events sent to actor
405
- // - Guard evaluation results
406
-
407
- // Signals accessible via actor properties, not snapshots
408
- console.log(actor.currentRoute.get()); // "/dashboard"
222
+ // config now includes auto-generated play.route handlers:
223
+ // on: { "play.route": [ { target: ".home", guard: e => e.to === "#home" }, ... ] }
224
+ const machine = setup({}).createMachine(config);
409
225
  ```
410
226
 
411
- ## Metadata Conventions
227
+ > **Note:** Every state with `meta.route` must also have an explicit `id` field; omitting it throws `MissingStateIdError` at machine-definition time.
412
228
 
413
- ### Route Metadata
229
+ #### Other routing exports
414
230
 
415
- ```typescript
416
- // meta.route marks states as routable
417
- states: {
418
- dashboard: {
419
- id: "dashboard",
420
- meta: {
421
- route: "/dashboard", // URL path - marks state as routable
422
- },
423
- },
424
- }
425
-
426
- // Parameters
427
- meta: {
428
- route: "/profile/:userId", // Required parameter
429
- route: "/settings/:section?", // Optional parameter
430
- }
431
-
432
- // Inheritance
433
- meta: {
434
- route: "/absolute", // Starts with / → doesn't inherit parent route
435
- route: "relative", // Doesn't start with / → inherits parent route
436
- }
437
- ```
438
-
439
- ### View Metadata
231
+ | Export | Description |
232
+ | ---------------------------------- | ------------------------------------------------------------------------- |
233
+ | `deriveRoute(meta)` | Extract the route template string from a state's metadata object |
234
+ | `isAbsoluteRoute(route)` | Returns `true` if the route string is an absolute URL path |
235
+ | `buildRouteUrl(template, context)` | Substitute `:param` placeholders in a route template using context values |
440
236
 
441
- View metadata drives the `currentView` signal. The `spec` field follows the `@json-render/core` `Spec` shape:
442
-
443
- ```typescript
444
- meta: {
445
- view: {
446
- component: "Dashboard", // Component name — must exist in the registry
447
- spec: {
448
- root: "root", // Root element key
449
- state: { tab: "general" }, // Optional: initial UI state (form values etc.)
450
- elements: {
451
- root: {
452
- type: "Dashboard",
453
- props: { title: "Dashboard" }, // Static props (non-undefined = cannot be overridden by route params)
454
- children: [],
455
- },
456
- },
457
- },
458
- },
459
- }
460
- ```
237
+ ---
461
238
 
462
- **Prop slots:** Declare a prop as `undefined` to allow URL params or context fields to fill
463
- it in automatically (see [Prop Enrichment from Routing and Context](#prop-enrichment-from-routing-and-context)):
239
+ ## Exported Types
464
240
 
465
241
  ```typescript
466
- // Route: /settings/:section? Context: { username: "alice" }
467
- spec: {
468
- root: "root",
469
- contextProps: ["username"], // expose context.username to components
470
- elements: {
471
- root: { type: "Settings", props: { section: undefined, username: undefined, theme: "dark" }, children: [] },
472
- },
473
- }
474
- // After play.route to /settings/profile:
475
- // Component receives: { section: "profile", username: "alice", theme: "dark" }
476
- // section → from context.params (URL)
477
- // username from contextProps (context)
478
- // theme → from spec (explicit value, untouched)
479
- ```
480
-
481
- ## Error Handling
482
-
483
- All runtime errors thrown by this package extend `PlayError` from `@xmachines/play` and
484
- are exported from the `./errors` subpath:
485
-
486
- ```typescript
487
- import { MissingRouteParamError, InvalidEventError } from "@xmachines/play-xstate/errors";
488
- ```
489
-
490
- | Class | Code | When thrown |
491
- | ------------------------ | -------------------------- | -------------------------------------------------------- |
492
- | `MissingRouteParamError` | `PLAY_ROUTE_PARAM_MISSING` | Required `:param` has no matching value in actor context |
493
- | `InvalidEventError` | `PLAY_INVALID_EVENT` | `actor.send()` called with a non-object value |
494
-
495
- `MissingRouteParamError` carries `param` and `template` fields for programmatic access.
496
- `InvalidEventError` carries a `detail` property with the offending value.
497
-
498
- > **Note:** `currentRoute` does NOT throw `MissingRouteParamError` — it catches the error internally and returns `null` instead, keeping signal watchers stable during transient states.
499
-
500
- ```typescript
501
- import { MissingRouteParamError, InvalidEventError } from "@xmachines/play-xstate/errors";
502
-
503
- try {
504
- actor.send(null as any);
505
- } catch (err) {
506
- if (err instanceof InvalidEventError) {
507
- console.error("Non-object event passed to actor.send():", err.detail);
508
- }
509
- }
510
-
511
- try {
512
- const route = actor.currentRoute.get();
513
- } catch (err) {
514
- if (err instanceof MissingRouteParamError) {
515
- console.error(`Missing "${err.param}" for template "${err.template}"`);
516
- }
517
- }
242
+ import type {
243
+ PlayerConfig, // definePlayer() config argument shape
244
+ PlayerOptions, // Lifecycle hooks (onStart, onStop, onTransition, onStateChange, onError)
245
+ PlayerFactory, // Factory function returned by definePlayer()
246
+ PlayerFactoryResumeOptions, // { snapshot? } for restoring actor state
247
+ Guard, // Single XState guard predicate
248
+ GuardArray, // Array of guards for compose helpers
249
+ ComposedGuard, // Return type of composeGuards / composeGuardsOr / negateGuard
250
+ RouteMachineConfig, // Minimal machine config accepted by formatPlayRouteTransitions
251
+ RouteStateNode, // Single state node shape used during route crawling
252
+ RouteContext, // Context shape expected by buildRouteUrl ({ params?, query? })
253
+ } from "@xmachines/play-xstate";
518
254
  ```
519
255
 
520
- ## Prop Enrichment from Routing and Context
521
-
522
- When `currentView` is derived, each spec element's `props` are enriched from two sources.
523
- Both use the **opt-in slot** pattern: declare a prop as `undefined` in the spec to allow
524
- it to be filled in automatically.
525
-
526
- ### Merge priority (highest → lowest)
527
-
528
- | Source | When it applies | Wins over |
529
- | ---------------------------------- | -------------------------------- | ------------------------- |
530
- | Explicit non-`undefined` spec prop | Always | Everything |
531
- | URL route param (`context.params`) | State has a `:param` URL segment | `contextProps` values |
532
- | `contextProps` field | Listed in `spec.contextProps` | Nothing (lowest priority) |
533
-
534
- ### URL route parameters
535
-
536
- URL path parameters (`:section?`, `:username`) are filled automatically — declare the prop
537
- as `undefined` to opt in:
538
-
539
- ```typescript
540
- // Route: /settings/:section?
541
- elements: {
542
- root: { type: "Settings", props: { section: undefined, user: "alice" }, children: [] }
543
- }
256
+ ---
544
257
 
545
- // play.route event: { to: "#settings", params: { section: "profile" } }
546
- // Component receives: { section: "profile", user: "alice" }
547
- ```
258
+ ## Error Classes
548
259
 
549
- Extra params not declared in the spec are also passed through:
260
+ Error classes are exported from the `@xmachines/play-xstate/errors` sub-path to keep the main bundle lean.
550
261
 
551
262
  ```typescript
552
- // params: { section: "profile", tab: "security" }
553
- // Component receives: { section: "profile", tab: "security", user: "alice" }
263
+ import {
264
+ MissingRouteParamError, // Required :param absent from context when resolving currentRoute
265
+ MissingQueryContextError, // context.params present but context.query missing
266
+ MissingStateIdError, // meta.route declared without a state id field
267
+ InvalidMachineError, // PlayerActor constructed with a non-object machine
268
+ InvalidEventError, // actor.send() called with null/undefined/non-object
269
+ InvalidRouteMetadataError, // meta.route is neither a string nor { path: string }
270
+ EmptyGuardArrayError, // composeGuards/composeGuardsOr called with empty array
271
+ } from "@xmachines/play-xstate/errors";
554
272
  ```
555
273
 
556
- ### Context fields via `contextProps`
274
+ All error classes extend `PlayError` from `@xmachines/play` and carry typed detail fields (`param`, `template`, `combinator`, etc.) for programmatic inspection without message parsing.
557
275
 
558
- For states where context data (e.g. `context.username`) should reach a component but there
559
- is no corresponding URL param, declare `contextProps` in the spec as an explicit allowlist:
276
+ ---
560
277
 
561
- ```typescript
562
- // Dashboard at /dashboard — no :username URL param, but we want to show context.username
563
- spec: {
564
- root: "root",
565
- contextProps: ["username"], // ← explicit allowlist
566
- elements: {
567
- root: { type: "Dashboard", props: { username: undefined }, children: [] },
568
- },
569
- }
570
-
571
- // After auth.login sets context.username = "alice":
572
- // Component receives: { username: "alice" }
573
- ```
278
+ ## Testing
574
279
 
575
- **Nothing leaks from context without an explicit `contextProps` declaration.**
576
- Only the named fields are ever exposed. `null` and `undefined` context values are excluded.
577
-
578
- If both a route param and a `contextProps` field share the same key, the route param wins:
280
+ ```bash
281
+ # Run tests for this package in isolation
282
+ npm test -w packages/play-xstate
579
283
 
580
- ```typescript
581
- // context.username = "alice" (logged-in user)
582
- // play.route to /profile/demo → context.params.username = "demo"
583
- // contextProps: ["username"], props: { username: undefined }
584
- // Component receives: { username: "demo" } ← route param wins
284
+ # Watch mode
285
+ npm run test:watch -w packages/play-xstate
585
286
  ```
586
287
 
587
- ## Architecture
588
-
589
- This package implements Play RFC requirements:
288
+ Tests use [Vitest](https://vitest.dev/) and live in `packages/play-xstate/test/`.
590
289
 
591
- **Architectural Invariants:**
592
-
593
- - **Actor Authority (INV-01):** Guards decide navigation validity
594
- - **Strict Separation (INV-02):** Zero framework imports
595
- - **Signal-Only Reactivity (INV-05):** All state via TC39 Signals
596
-
597
- **XState DevTools:** Maintains Inspector compatibility — snapshots remain pure XState format, signals accessible via actor properties.
598
-
599
- **Routing:**
600
-
601
- - `meta.route` property marks states as routable
602
- - `play.route` events support parameters (enhancement)
603
- - Route extraction for URL patterns
604
-
605
- **Note:** Route parameter extraction uses URLPattern API. See [@xmachines/play-tanstack-react-router](../play-tanstack-react-router/README.md) for polyfill requirements.
606
-
607
- ## Related Packages
608
-
609
- - **[@xmachines/play-actor](../play-actor/README.md)** - AbstractActor base class
610
- - **[@xmachines/play-signals](../play-signals/README.md)** - TC39 Signals polyfill
611
- - **[@xmachines/play-router](../play-router/README.md)** - Route extraction utilities
612
- - **[@xmachines/play-react](../play-react/README.md)** - React renderer
613
- - **[@xmachines/play-vue](../play-vue/README.md)** - Vue renderer
614
- - **[@xmachines/play-solid](../play-solid/README.md)** - SolidJS renderer
615
- - **[@xmachines/play-dom](../play-dom/README.md)** - Vanilla DOM renderer
616
- - **[@xmachines/play](../play/README.md)** - Protocol types (PlayEvent)
290
+ ---
617
291
 
618
292
  ## License
619
293
 
620
- Copyright (c) 2016 [Mikael Karon](mailto:mikael@karon.se). All rights reserved.
621
-
622
- This work is licensed under the terms of the MIT license.
623
- For a copy, see <https://opensource.org/licenses/MIT>.
294
+ MIT see [LICENSE](LICENSE) for details.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@xmachines/play-xstate",
3
- "version": "1.0.0-beta.45",
3
+ "version": "1.0.0-beta.48",
4
4
  "description": "XState v5 adapter for Play Architecture",
5
5
  "keywords": [
6
6
  "actor-model",
@@ -43,21 +43,21 @@
43
43
  "prepublishOnly": "npm run build"
44
44
  },
45
45
  "dependencies": {
46
- "@xmachines/play": "1.0.0-beta.45",
47
- "@xmachines/play-actor": "1.0.0-beta.45",
48
- "@xmachines/play-signals": "1.0.0-beta.45",
46
+ "@xmachines/play": "1.0.0-beta.48",
47
+ "@xmachines/play-actor": "1.0.0-beta.48",
48
+ "@xmachines/play-signals": "1.0.0-beta.48",
49
49
  "dequal": "^2.0.3"
50
50
  },
51
51
  "devDependencies": {
52
- "@xmachines/shared": "1.0.0-beta.45",
53
- "oxfmt": "^0.45.0",
54
- "oxlint": "^1.60.0",
52
+ "@xmachines/shared": "1.0.0-beta.48",
53
+ "oxfmt": "^0.47.0",
54
+ "oxlint": "^1.62.0",
55
55
  "typescript": "^5.9.3 || ^6.0.3",
56
- "vitest": "^4.1.4",
57
- "xstate": "^5.30.0"
56
+ "vitest": "^4.1.5",
57
+ "xstate": "^5.31.0"
58
58
  },
59
59
  "peerDependencies": {
60
- "xstate": "^5.30.0"
60
+ "xstate": "^5.31.0"
61
61
  },
62
62
  "_devDependencies_note": "xstate appears in both peerDependencies and devDependencies intentionally. devDependencies provides workspace resolution for local builds, tests, and typechecking. peerDependencies declares the consumer version constraint. Both are pinned to ^5.30.0 to prevent drift."
63
63
  }