@xmachines/play-xstate 1.0.0-beta.46 → 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.
- package/README.md +167 -496
- package/package.json +10 -10
package/README.md
CHANGED
|
@@ -1,623 +1,294 @@
|
|
|
1
|
-
|
|
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
|
-
|
|
3
|
+
# @xmachines/play-xstate
|
|
8
4
|
|
|
9
|
-
|
|
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
|
-
|
|
7
|
+
[](https://opensource.org/licenses/MIT)
|
|
8
|
+
[](https://www.npmjs.com/package/@xmachines/play-xstate)
|
|
12
9
|
|
|
13
|
-
|
|
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
|
-
|
|
12
|
+
---
|
|
18
13
|
|
|
19
14
|
## Installation
|
|
20
15
|
|
|
21
16
|
```bash
|
|
22
|
-
npm install xstate
|
|
23
|
-
npm install @xmachines/play-xstate
|
|
17
|
+
npm install @xmachines/play-xstate xstate
|
|
24
18
|
```
|
|
25
19
|
|
|
26
|
-
|
|
20
|
+
`xstate ^5.30.0` is a peer dependency and must be installed alongside this package.
|
|
27
21
|
|
|
28
|
-
|
|
22
|
+
---
|
|
29
23
|
|
|
30
24
|
## Quick Start
|
|
31
25
|
|
|
32
26
|
```typescript
|
|
33
|
-
import { setup
|
|
34
|
-
import { definePlayer
|
|
27
|
+
import { setup } from "xstate";
|
|
28
|
+
import { definePlayer } from "@xmachines/play-xstate";
|
|
35
29
|
|
|
36
|
-
// 1. Define XState machine
|
|
37
|
-
const machine = setup({
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
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
|
-
})
|
|
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.
|
|
42
|
+
// 3. Instantiate and start an actor
|
|
105
43
|
const actor = createPlayer();
|
|
106
44
|
actor.start();
|
|
107
45
|
|
|
108
|
-
// 4. Observe
|
|
109
|
-
console.log(actor.currentRoute.get()); // "/
|
|
110
|
-
console.log(actor.
|
|
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.
|
|
113
|
-
actor.
|
|
114
|
-
```
|
|
50
|
+
// 5. Send events — machine guards decide transitions
|
|
51
|
+
actor.send({ type: "activate" });
|
|
115
52
|
|
|
116
|
-
|
|
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
|
-
|
|
56
|
+
---
|
|
130
57
|
|
|
131
|
-
|
|
132
|
-
- `options` (optional) - Lifecycle hooks
|
|
58
|
+
## API Summary
|
|
133
59
|
|
|
134
|
-
|
|
60
|
+
### `definePlayer(config)`
|
|
135
61
|
|
|
136
|
-
|
|
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
|
|
80
|
+
machine,
|
|
141
81
|
options: {
|
|
142
|
-
onStart: (actor) => console.log("
|
|
143
|
-
|
|
144
|
-
|
|
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
|
-
|
|
150
|
-
const
|
|
151
|
-
|
|
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
|
-
|
|
95
|
+
#### `PlayerFactory` signature
|
|
155
96
|
|
|
156
97
|
```typescript
|
|
157
|
-
|
|
98
|
+
type PlayerFactory<TMachine> = (
|
|
99
|
+
input?: InputFrom<TMachine>,
|
|
100
|
+
options?: PlayerFactoryResumeOptions<TMachine>,
|
|
101
|
+
) => PlayerActor<TMachine>;
|
|
102
|
+
```
|
|
158
103
|
|
|
159
|
-
|
|
160
|
-
actor.start();
|
|
104
|
+
#### Restoring from a snapshot
|
|
161
105
|
|
|
162
|
-
|
|
106
|
+
```typescript
|
|
163
107
|
const snapshot = actor.getSnapshot();
|
|
164
108
|
actor.stop();
|
|
165
109
|
|
|
166
|
-
|
|
167
|
-
|
|
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
|
-
|
|
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
|
-
|
|
118
|
+
### `PlayerActor<TMachine>`
|
|
185
119
|
|
|
186
|
-
- `
|
|
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
|
-
|
|
122
|
+
#### Signals
|
|
193
123
|
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
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
|
-
|
|
131
|
+
#### Methods
|
|
202
132
|
|
|
203
|
-
|
|
204
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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.
|
|
154
|
+
actor.start();
|
|
226
155
|
```
|
|
227
156
|
|
|
228
|
-
|
|
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:
|
|
243
|
-
|
|
176
|
+
isLoggedIn: ({ context }) => !!context.userId,
|
|
177
|
+
hasAdminRole: ({ context }) => context.role === "admin",
|
|
244
178
|
},
|
|
245
179
|
}).createMachine({
|
|
246
180
|
on: {
|
|
247
181
|
accessAdmin: {
|
|
248
|
-
|
|
249
|
-
guard: composeGuards(["isLoggedIn", "isAdmin"]),
|
|
182
|
+
guard: composeGuards(["isLoggedIn", "hasAdminRole"]),
|
|
250
183
|
target: "adminPanel",
|
|
251
184
|
},
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
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
|
-
|
|
194
|
+
---
|
|
267
195
|
|
|
268
|
-
|
|
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
|
-
|
|
198
|
+
Helper functions for declarative route configuration in XState machines.
|
|
276
199
|
|
|
277
|
-
|
|
200
|
+
#### `formatPlayRouteTransitions(machineConfig)`
|
|
278
201
|
|
|
279
|
-
|
|
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 {
|
|
206
|
+
import { formatPlayRouteTransitions } from "@xmachines/play-xstate";
|
|
284
207
|
|
|
285
|
-
|
|
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: "/"
|
|
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
|
-
|
|
377
|
-
|
|
378
|
-
|
|
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
|
-
|
|
395
|
-
|
|
396
|
-
const
|
|
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
|
-
|
|
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
|
-
|
|
229
|
+
#### Other routing exports
|
|
414
230
|
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
//
|
|
475
|
-
//
|
|
476
|
-
//
|
|
477
|
-
|
|
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
|
-
|
|
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
|
-
|
|
546
|
-
// Component receives: { section: "profile", user: "alice" }
|
|
547
|
-
```
|
|
258
|
+
## Error Classes
|
|
548
259
|
|
|
549
|
-
|
|
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
|
-
|
|
553
|
-
//
|
|
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
|
-
|
|
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
|
-
|
|
559
|
-
is no corresponding URL param, declare `contextProps` in the spec as an explicit allowlist:
|
|
276
|
+
---
|
|
560
277
|
|
|
561
|
-
|
|
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
|
-
|
|
576
|
-
|
|
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
|
-
|
|
581
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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.
|
|
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.
|
|
47
|
-
"@xmachines/play-actor": "1.0.0-beta.
|
|
48
|
-
"@xmachines/play-signals": "1.0.0-beta.
|
|
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.
|
|
53
|
-
"oxfmt": "^0.
|
|
54
|
-
"oxlint": "^1.
|
|
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.
|
|
57
|
-
"xstate": "^5.
|
|
56
|
+
"vitest": "^4.1.5",
|
|
57
|
+
"xstate": "^5.31.0"
|
|
58
58
|
},
|
|
59
59
|
"peerDependencies": {
|
|
60
|
-
"xstate": "^5.
|
|
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
|
}
|