@xmachines/play-xstate 1.0.0-beta.4 → 1.0.0-beta.41
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/LICENSE +21 -0
- package/README.md +276 -107
- package/dist/define-player.d.ts +7 -57
- package/dist/define-player.d.ts.map +1 -1
- package/dist/define-player.js +17 -61
- package/dist/define-player.js.map +1 -1
- package/dist/define-player.typecheck.js +6 -0
- package/dist/define-player.typecheck.js.map +1 -1
- package/dist/errors.d.ts +169 -0
- package/dist/errors.d.ts.map +1 -0
- package/dist/errors.js +202 -0
- package/dist/errors.js.map +1 -0
- package/dist/guards/compose.d.ts +14 -3
- package/dist/guards/compose.d.ts.map +1 -1
- package/dist/guards/compose.js +29 -2
- package/dist/guards/compose.js.map +1 -1
- package/dist/guards/helpers.d.ts +13 -17
- package/dist/guards/helpers.d.ts.map +1 -1
- package/dist/guards/helpers.js +20 -25
- package/dist/guards/helpers.js.map +1 -1
- package/dist/guards/index.d.ts +2 -1
- package/dist/guards/index.d.ts.map +1 -1
- package/dist/guards/index.js +1 -1
- package/dist/guards/index.js.map +1 -1
- package/dist/guards/types.d.ts +3 -2
- package/dist/guards/types.d.ts.map +1 -1
- package/dist/index.d.ts +5 -8
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +3 -5
- package/dist/index.js.map +1 -1
- package/dist/player-actor.d.ts +124 -26
- package/dist/player-actor.d.ts.map +1 -1
- package/dist/player-actor.js +361 -101
- package/dist/player-actor.js.map +1 -1
- package/dist/player-actor.typecheck.js +9 -5
- package/dist/player-actor.typecheck.js.map +1 -1
- package/dist/routing/build-url.d.ts +27 -16
- package/dist/routing/build-url.d.ts.map +1 -1
- package/dist/routing/build-url.js +44 -26
- package/dist/routing/build-url.js.map +1 -1
- package/dist/routing/derive-route.d.ts +2 -2
- package/dist/routing/derive-route.d.ts.map +1 -1
- package/dist/routing/derive-route.js +5 -4
- package/dist/routing/derive-route.js.map +1 -1
- package/dist/routing/format-play-route-transitions.d.ts +31 -7
- package/dist/routing/format-play-route-transitions.d.ts.map +1 -1
- package/dist/routing/format-play-route-transitions.js +28 -24
- package/dist/routing/format-play-route-transitions.js.map +1 -1
- package/dist/routing/types.d.ts +15 -3
- package/dist/routing/types.d.ts.map +1 -1
- package/dist/signals/state-signal.d.ts +4 -2
- package/dist/signals/state-signal.d.ts.map +1 -1
- package/dist/signals/state-signal.js +5 -2
- package/dist/signals/state-signal.js.map +1 -1
- package/dist/types.d.ts +12 -6
- package/dist/types.d.ts.map +1 -1
- package/package.json +23 -12
- package/dist/catalog/index.d.ts +0 -13
- package/dist/catalog/index.d.ts.map +0 -1
- package/dist/catalog/index.js +0 -11
- package/dist/catalog/index.js.map +0 -1
- package/dist/catalog/types.d.ts +0 -36
- package/dist/catalog/types.d.ts.map +0 -1
- package/dist/catalog/types.js +0 -2
- package/dist/catalog/types.js.map +0 -1
- package/dist/catalog/validate-binding.d.ts +0 -21
- package/dist/catalog/validate-binding.d.ts.map +0 -1
- package/dist/catalog/validate-binding.js +0 -30
- package/dist/catalog/validate-binding.js.map +0 -1
- package/dist/catalog/validate-props.d.ts +0 -49
- package/dist/catalog/validate-props.d.ts.map +0 -1
- package/dist/catalog/validate-props.js +0 -103
- package/dist/catalog/validate-props.js.map +0 -1
- package/dist/catalog/validate-props.typecheck.d.ts +0 -2
- package/dist/catalog/validate-props.typecheck.d.ts.map +0 -1
- package/dist/catalog/validate-props.typecheck.js +0 -6
- package/dist/catalog/validate-props.typecheck.js.map +0 -1
- package/dist/signals/debounce.d.ts +0 -18
- package/dist/signals/debounce.d.ts.map +0 -1
- package/dist/signals/debounce.js +0 -35
- package/dist/signals/debounce.js.map +0 -1
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Mikael Karon
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
CHANGED
|
@@ -6,9 +6,9 @@ Transform declarative state machines into live actors with TC39 Signals and para
|
|
|
6
6
|
|
|
7
7
|
## Overview
|
|
8
8
|
|
|
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
|
|
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.
|
|
10
10
|
|
|
11
|
-
Per [
|
|
11
|
+
Per [Play RFC](../docs/rfc/play.md), this package implements:
|
|
12
12
|
|
|
13
13
|
- **Actor Authority (INV-01):** State machine guards decide navigation validity
|
|
14
14
|
- **Strict Separation (INV-02):** Zero React/framework imports in business logic
|
|
@@ -19,89 +19,97 @@ Per [RFC Play v1](https://gitlab.com/xmachin-es/rfc/-/blob/main/src/play-v1.md),
|
|
|
19
19
|
## Installation
|
|
20
20
|
|
|
21
21
|
```bash
|
|
22
|
-
npm install xstate@^5.0.0
|
|
22
|
+
npm install xstate@^5.0.0
|
|
23
23
|
npm install @xmachines/play-xstate
|
|
24
24
|
```
|
|
25
25
|
|
|
26
|
-
## Current Exports
|
|
27
|
-
|
|
28
|
-
- `definePlayer`, `PlayerActor`
|
|
29
|
-
- player types: `PlayerConfig`, `PlayerOptions`, `PlayerFactory`
|
|
30
|
-
- guard utilities: `composeGuards`, `composeGuardsOr`, `negateGuard`, `hasContext`, `eventMatches`, `stateMatches`
|
|
31
|
-
- routing utilities: `deriveRoute`, `isAbsoluteRoute`, `buildRouteUrl`, `formatPlayRouteTransitions`
|
|
32
|
-
- catalog utilities: `validateComponentBinding`, `validateViewProps`, `mergeViewProps`
|
|
33
|
-
|
|
34
26
|
**Peer dependencies:**
|
|
35
27
|
|
|
36
|
-
- `xstate` ^5.0.0
|
|
37
|
-
- `zod` ^3.23.0 - Schema validation for component props
|
|
28
|
+
- `xstate` ^5.0.0 — State machine runtime
|
|
38
29
|
|
|
39
30
|
## Quick Start
|
|
40
31
|
|
|
41
32
|
```typescript
|
|
42
|
-
import { setup } from "xstate";
|
|
43
|
-
import {
|
|
44
|
-
import { definePlayer } from "@xmachines/play-xstate";
|
|
45
|
-
import { defineCatalog } from "@xmachines/play-catalog";
|
|
33
|
+
import { setup, assign } from "xstate";
|
|
34
|
+
import { definePlayer, formatPlayRouteTransitions } from "@xmachines/play-xstate";
|
|
46
35
|
|
|
47
|
-
// 1. Define XState machine with meta.route
|
|
36
|
+
// 1. Define XState machine with meta.route and view spec
|
|
48
37
|
const machine = setup({
|
|
49
38
|
types: {
|
|
50
|
-
context: {} as {
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
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" },
|
|
55
48
|
},
|
|
56
|
-
}).createMachine(
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
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
|
+
},
|
|
66
69
|
},
|
|
67
|
-
|
|
68
|
-
"
|
|
69
|
-
|
|
70
|
-
|
|
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
|
+
},
|
|
71
83
|
},
|
|
84
|
+
always: [{ target: "login", guard: ({ context }) => !context.isAuthenticated }],
|
|
72
85
|
},
|
|
73
86
|
},
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
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 }),
|
|
79
96
|
},
|
|
80
97
|
},
|
|
81
|
-
},
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
// 2. Define catalog with Zod schemas
|
|
85
|
-
const catalog = defineCatalog({
|
|
86
|
-
LoginForm: z.object({ error: z.string().optional() }),
|
|
87
|
-
Dashboard: z.object({ userId: z.string() }),
|
|
88
|
-
});
|
|
98
|
+
}),
|
|
99
|
+
);
|
|
89
100
|
|
|
90
|
-
//
|
|
91
|
-
const createPlayer = definePlayer({ machine
|
|
101
|
+
// 2. Create player factory
|
|
102
|
+
const createPlayer = definePlayer({ machine });
|
|
92
103
|
|
|
93
|
-
//
|
|
94
|
-
const actor = createPlayer(
|
|
104
|
+
// 3. Create and start actor
|
|
105
|
+
const actor = createPlayer();
|
|
95
106
|
actor.start();
|
|
96
107
|
|
|
97
|
-
//
|
|
98
|
-
actor.send({ type: "play.route", to: "/login" });
|
|
99
|
-
|
|
100
|
-
// 6. Observe signals
|
|
108
|
+
// 4. Observe signals
|
|
101
109
|
console.log(actor.currentRoute.get()); // "/login"
|
|
102
|
-
console.log(actor.currentView.get()); // { component: "
|
|
110
|
+
console.log(actor.currentView.get()); // { component: "Login", spec: {...} }
|
|
103
111
|
|
|
104
|
-
//
|
|
112
|
+
// 5. Cleanup
|
|
105
113
|
actor.dispose();
|
|
106
114
|
```
|
|
107
115
|
|
|
@@ -109,12 +117,11 @@ actor.dispose();
|
|
|
109
117
|
|
|
110
118
|
### definePlayer()
|
|
111
119
|
|
|
112
|
-
Create a player factory from XState machine
|
|
120
|
+
Create a player factory from an XState machine:
|
|
113
121
|
|
|
114
122
|
```typescript
|
|
115
|
-
const createPlayer = definePlayer<TMachine
|
|
123
|
+
const createPlayer = definePlayer<TMachine>({
|
|
116
124
|
machine: AnyStateMachine,
|
|
117
|
-
catalog?: Catalog,
|
|
118
125
|
options?: PlayerOptions,
|
|
119
126
|
}): PlayerFactory;
|
|
120
127
|
```
|
|
@@ -122,17 +129,15 @@ const createPlayer = definePlayer<TMachine, TCatalog>({
|
|
|
122
129
|
**Config:**
|
|
123
130
|
|
|
124
131
|
- `machine` (required) - XState v5 state machine
|
|
125
|
-
- `catalog` (optional) - UI component catalog with Zod schemas
|
|
126
132
|
- `options` (optional) - Lifecycle hooks
|
|
127
133
|
|
|
128
|
-
**Returns:** Factory function `(input?) => PlayerActor`
|
|
134
|
+
**Returns:** Factory function `(input?, { snapshot }?) => PlayerActor`
|
|
129
135
|
|
|
130
136
|
**Example:**
|
|
131
137
|
|
|
132
138
|
```typescript
|
|
133
139
|
const createPlayer = definePlayer({
|
|
134
140
|
machine: authMachine,
|
|
135
|
-
catalog: authCatalog,
|
|
136
141
|
options: {
|
|
137
142
|
onStart: (actor) => console.log("Started:", actor.id),
|
|
138
143
|
onTransition: (actor, prev, next) => {
|
|
@@ -146,26 +151,62 @@ const actor2 = createPlayer({ userId: "user2" });
|
|
|
146
151
|
// Multiple independent actor instances
|
|
147
152
|
```
|
|
148
153
|
|
|
154
|
+
**Restoring from a snapshot:**
|
|
155
|
+
|
|
156
|
+
```typescript
|
|
157
|
+
const createPlayer = definePlayer({ machine });
|
|
158
|
+
|
|
159
|
+
const actor = createPlayer();
|
|
160
|
+
actor.start();
|
|
161
|
+
|
|
162
|
+
// ...persist the actor snapshot however your app stores it
|
|
163
|
+
const snapshot = actor.getSnapshot();
|
|
164
|
+
actor.stop();
|
|
165
|
+
|
|
166
|
+
const restoredActor = createPlayer(undefined, { snapshot });
|
|
167
|
+
restoredActor.start();
|
|
168
|
+
```
|
|
169
|
+
|
|
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
|
+
|
|
149
174
|
### PlayerActor
|
|
150
175
|
|
|
151
176
|
Concrete actor implementing Play signal protocol:
|
|
152
177
|
|
|
153
178
|
**Signal Properties:**
|
|
154
179
|
|
|
155
|
-
- `state: Signal.State<
|
|
156
|
-
- `currentRoute: Signal.Computed<string | null>`
|
|
157
|
-
- `currentView: Signal.
|
|
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)).
|
|
158
183
|
|
|
159
|
-
**
|
|
184
|
+
**Lifecycle ordering:**
|
|
160
185
|
|
|
161
|
-
- `
|
|
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.
|
|
162
191
|
|
|
163
192
|
**Methods:**
|
|
164
193
|
|
|
165
|
-
- `start()`
|
|
166
|
-
- `stop()`
|
|
167
|
-
- `send(event
|
|
168
|
-
- `
|
|
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>`
|
|
200
|
+
|
|
201
|
+
**Routing failure behavior:**
|
|
202
|
+
|
|
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`).
|
|
205
|
+
|
|
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).
|
|
169
210
|
|
|
170
211
|
**Example:**
|
|
171
212
|
|
|
@@ -193,7 +234,7 @@ import {
|
|
|
193
234
|
negateGuard,
|
|
194
235
|
hasContext,
|
|
195
236
|
eventMatches,
|
|
196
|
-
|
|
237
|
+
contextFieldMatches,
|
|
197
238
|
} from "@xmachines/play-xstate";
|
|
198
239
|
|
|
199
240
|
const machine = setup({
|
|
@@ -226,13 +267,11 @@ const machine = setup({
|
|
|
226
267
|
|
|
227
268
|
- `hasContext(path: string)` - Check if context property is truthy
|
|
228
269
|
- `eventMatches(type: string)` - Check event type
|
|
229
|
-
- `
|
|
270
|
+
- `contextFieldMatches(fieldPath: string, expectedValue: unknown)` - Check context field with strict structural equality
|
|
230
271
|
- `composeGuards(guards: Array)` - AND composition
|
|
231
272
|
- `composeGuardsOr(guards: Array)` - OR composition
|
|
232
273
|
- `negateGuard(guard)` - NOT composition
|
|
233
274
|
|
|
234
|
-
**Complete API:** See [API Documentation](../../docs/api/@xmachines/play-xstate)
|
|
235
|
-
|
|
236
275
|
## Examples
|
|
237
276
|
|
|
238
277
|
### Guard Placement Philosophy
|
|
@@ -242,7 +281,6 @@ const machine = setup({
|
|
|
242
281
|
```typescript
|
|
243
282
|
import { setup } from "xstate";
|
|
244
283
|
import { definePlayer, formatPlayRouteTransitions } from "@xmachines/play-xstate";
|
|
245
|
-
import { defineCatalog } from "@xmachines/play-catalog";
|
|
246
284
|
|
|
247
285
|
// Pattern 1: RECOMMENDED - Use formatPlayRouteTransitions utility
|
|
248
286
|
const machineConfig = {
|
|
@@ -275,17 +313,18 @@ const machineConfig = {
|
|
|
275
313
|
// formatPlayRouteTransitions handles routing infrastructure
|
|
276
314
|
const machine = setup({
|
|
277
315
|
types: {
|
|
278
|
-
|
|
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" },
|
|
279
324
|
},
|
|
280
325
|
}).createMachine(formatPlayRouteTransitions(machineConfig));
|
|
281
326
|
|
|
282
|
-
const
|
|
283
|
-
Home,
|
|
284
|
-
Dashboard,
|
|
285
|
-
Login,
|
|
286
|
-
});
|
|
287
|
-
|
|
288
|
-
const createPlayer = definePlayer({ machine, catalog });
|
|
327
|
+
const createPlayer = definePlayer({ machine });
|
|
289
328
|
const actor = createPlayer();
|
|
290
329
|
actor.start();
|
|
291
330
|
|
|
@@ -320,7 +359,6 @@ on: {
|
|
|
320
359
|
```typescript
|
|
321
360
|
const createPlayer = definePlayer({
|
|
322
361
|
machine,
|
|
323
|
-
catalog,
|
|
324
362
|
options: {
|
|
325
363
|
onStart: (actor) => {
|
|
326
364
|
console.log("Actor started:", actor.id);
|
|
@@ -355,7 +393,7 @@ import { definePlayer } from "@xmachines/play-xstate";
|
|
|
355
393
|
|
|
356
394
|
const { inspect } = createBrowserInspector();
|
|
357
395
|
|
|
358
|
-
const createPlayer = definePlayer({ machine
|
|
396
|
+
const createPlayer = definePlayer({ machine });
|
|
359
397
|
const actor = createPlayer();
|
|
360
398
|
actor.start();
|
|
361
399
|
|
|
@@ -400,30 +438,155 @@ meta: {
|
|
|
400
438
|
|
|
401
439
|
### View Metadata
|
|
402
440
|
|
|
441
|
+
View metadata drives the `currentView` signal. The `spec` field follows the `@json-render/core` `Spec` shape:
|
|
442
|
+
|
|
403
443
|
```typescript
|
|
404
444
|
meta: {
|
|
405
445
|
view: {
|
|
406
|
-
component: "Dashboard",
|
|
407
|
-
|
|
408
|
-
|
|
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
|
+
},
|
|
409
458
|
},
|
|
410
459
|
}
|
|
460
|
+
```
|
|
411
461
|
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
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)):
|
|
464
|
+
|
|
465
|
+
```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
|
+
}
|
|
518
|
+
```
|
|
519
|
+
|
|
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: [] }
|
|
421
543
|
}
|
|
544
|
+
|
|
545
|
+
// play.route event: { to: "#settings", params: { section: "profile" } }
|
|
546
|
+
// Component receives: { section: "profile", user: "alice" }
|
|
547
|
+
```
|
|
548
|
+
|
|
549
|
+
Extra params not declared in the spec are also passed through:
|
|
550
|
+
|
|
551
|
+
```typescript
|
|
552
|
+
// params: { section: "profile", tab: "security" }
|
|
553
|
+
// Component receives: { section: "profile", tab: "security", user: "alice" }
|
|
554
|
+
```
|
|
555
|
+
|
|
556
|
+
### Context fields via `contextProps`
|
|
557
|
+
|
|
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:
|
|
560
|
+
|
|
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
|
+
```
|
|
574
|
+
|
|
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:
|
|
579
|
+
|
|
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
|
|
422
585
|
```
|
|
423
586
|
|
|
424
587
|
## Architecture
|
|
425
588
|
|
|
426
|
-
This package implements
|
|
589
|
+
This package implements Play RFC requirements:
|
|
427
590
|
|
|
428
591
|
**Architectural Invariants:**
|
|
429
592
|
|
|
@@ -439,16 +602,22 @@ This package implements RFC Play v1 requirements:
|
|
|
439
602
|
- `play.route` events support parameters (enhancement)
|
|
440
603
|
- Route extraction for URL patterns
|
|
441
604
|
|
|
442
|
-
**Note:** Route parameter extraction uses URLPattern API. See [@xmachines/play-tanstack-react-router
|
|
605
|
+
**Note:** Route parameter extraction uses URLPattern API. See [@xmachines/play-tanstack-react-router](../play-tanstack-react-router) for polyfill requirements.
|
|
443
606
|
|
|
444
607
|
## Related Packages
|
|
445
608
|
|
|
446
609
|
- **[@xmachines/play-actor](../play-actor)** - AbstractActor base class
|
|
447
610
|
- **[@xmachines/play-signals](../play-signals)** - TC39 Signals polyfill
|
|
448
|
-
- **[@xmachines/play-
|
|
449
|
-
- **[@xmachines/play-
|
|
611
|
+
- **[@xmachines/play-router](../play-router)** - Route extraction utilities
|
|
612
|
+
- **[@xmachines/play-react](../play-react)** - React renderer
|
|
613
|
+
- **[@xmachines/play-vue](../play-vue)** - Vue renderer
|
|
614
|
+
- **[@xmachines/play-solid](../play-solid)** - SolidJS renderer
|
|
615
|
+
- **[@xmachines/play-dom](../play-dom)** - Vanilla DOM renderer
|
|
450
616
|
- **[@xmachines/play](../play)** - Protocol types (PlayEvent)
|
|
451
617
|
|
|
452
618
|
## License
|
|
453
619
|
|
|
454
|
-
|
|
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>.
|
package/dist/define-player.d.ts
CHANGED
|
@@ -1,29 +1,23 @@
|
|
|
1
|
-
import type
|
|
1
|
+
import { type AnyStateMachine } from "xstate";
|
|
2
2
|
import type { PlayerConfig, PlayerFactory } from "./types.js";
|
|
3
3
|
/**
|
|
4
|
-
* Create a player factory from XState machine
|
|
4
|
+
* Create a player factory from an XState machine
|
|
5
5
|
*
|
|
6
|
-
* Factory pattern that accepts an XState v5 machine
|
|
6
|
+
* Factory pattern that accepts an XState v5 machine,
|
|
7
7
|
* returning a function that creates {@link PlayerActor} instances. This enables
|
|
8
8
|
* creating multiple actor instances from a single configuration, useful for
|
|
9
9
|
* testing, multi-instance scenarios, or server-side rendering.
|
|
10
10
|
*
|
|
11
|
-
* **Architectural Context:** Implements **Strict Separation (INV-02)** by accepting
|
|
12
|
-
* the machine definition (pure business logic) separately from the catalog (UI vocabulary).
|
|
13
|
-
* The machine references component names in `meta.view` without importing React/framework code.
|
|
14
|
-
*
|
|
15
11
|
* @typeParam TMachine - XState v5 state machine type
|
|
16
|
-
* @typeParam TCatalog - Optional UI component catalog type
|
|
17
12
|
*
|
|
18
13
|
* @param config - Player configuration object
|
|
19
14
|
* @param config.machine - XState v5 state machine
|
|
20
|
-
* @param config.catalog - Optional UI component catalog (allows machines without UI)
|
|
21
15
|
* @param config.options - Optional lifecycle hooks (onStart, onTransition, etc.)
|
|
22
16
|
*
|
|
23
17
|
* @returns Factory function that creates actor instances with optional input context
|
|
24
18
|
*
|
|
25
19
|
* @example
|
|
26
|
-
* Basic player factory
|
|
20
|
+
* Basic player factory
|
|
27
21
|
* ```typescript
|
|
28
22
|
* import { setup } from "xstate";
|
|
29
23
|
* import { definePlayer } from "@xmachines/play-xstate";
|
|
@@ -42,53 +36,9 @@ import type { PlayerConfig, PlayerFactory } from "./types.js";
|
|
|
42
36
|
* ```
|
|
43
37
|
*
|
|
44
38
|
* @example
|
|
45
|
-
* Player factory with catalog and parameter-aware routing
|
|
46
|
-
* ```typescript
|
|
47
|
-
* import { setup } from "xstate";
|
|
48
|
-
* import { defineCatalog } from "@xmachines/play-catalog";
|
|
49
|
-
* import { definePlayer } from "@xmachines/play-xstate";
|
|
50
|
-
* import { z } from "zod";
|
|
51
|
-
*
|
|
52
|
-
* const catalog = defineCatalog({
|
|
53
|
-
* HomePage: z.object({}),
|
|
54
|
-
* ProfilePage: z.object({ userId: z.string() })
|
|
55
|
-
* });
|
|
56
|
-
*
|
|
57
|
-
* const machine = setup({
|
|
58
|
-
* types: {
|
|
59
|
-
* context: {} as { userId: string },
|
|
60
|
-
* events: {} as { type: 'play.route'; to: string; params?: Record<string, string> }
|
|
61
|
-
* }
|
|
62
|
-
* }).createMachine({
|
|
63
|
-
* initial: 'home',
|
|
64
|
-
* context: { userId: '' },
|
|
65
|
-
* states: {
|
|
66
|
-
* home: {
|
|
67
|
-
* route: {},
|
|
68
|
-
* meta: {
|
|
69
|
-
* route: '/',
|
|
70
|
-
* view: { component: 'HomePage' }
|
|
71
|
-
* }
|
|
72
|
-
* },
|
|
73
|
-
* profile: {
|
|
74
|
-
* route: {},
|
|
75
|
-
* meta: {
|
|
76
|
-
* route: '/profile/:userId',
|
|
77
|
-
* view: { component: 'ProfilePage', userId: (ctx) => ctx.userId }
|
|
78
|
-
* }
|
|
79
|
-
* }
|
|
80
|
-
* }
|
|
81
|
-
* });
|
|
82
|
-
*
|
|
83
|
-
* const createPlayer = definePlayer({ machine, catalog });
|
|
84
|
-
* const actor = createPlayer({ userId: 'user123' });
|
|
85
|
-
* actor.start();
|
|
86
|
-
* ```
|
|
87
|
-
*
|
|
88
|
-
* @example
|
|
89
39
|
* Multiple actor instances from single factory
|
|
90
40
|
* ```typescript
|
|
91
|
-
* const createPlayer = definePlayer({ machine
|
|
41
|
+
* const createPlayer = definePlayer({ machine });
|
|
92
42
|
*
|
|
93
43
|
* // Create actors for different users
|
|
94
44
|
* const alice = createPlayer({ userId: 'alice' });
|
|
@@ -101,10 +51,10 @@ import type { PlayerConfig, PlayerFactory } from "./types.js";
|
|
|
101
51
|
* console.log(alice.state.get() !== bob.state.get());
|
|
102
52
|
* ```
|
|
103
53
|
*
|
|
104
|
-
* @see
|
|
54
|
+
* @see [Play RFC](../../docs/rfc/play.md)
|
|
105
55
|
* @see {@link PlayerActor} for the concrete actor implementation
|
|
106
56
|
* @see {@link PlayerConfig} for configuration options
|
|
107
57
|
* @see {@link PlayerFactory} for factory function signature
|
|
108
58
|
*/
|
|
109
|
-
export declare const definePlayer: <TMachine extends AnyStateMachine
|
|
59
|
+
export declare const definePlayer: <TMachine extends AnyStateMachine>(config: PlayerConfig<TMachine>) => PlayerFactory<TMachine>;
|
|
110
60
|
//# sourceMappingURL=define-player.d.ts.map
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"define-player.d.ts","sourceRoot":"","sources":["../src/define-player.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,
|
|
1
|
+
{"version":3,"file":"define-player.d.ts","sourceRoot":"","sources":["../src/define-player.ts"],"names":[],"mappings":"AAAA,OAAO,EAAe,KAAK,eAAe,EAA2C,MAAM,QAAQ,CAAC;AACpG,OAAO,KAAK,EAAE,YAAY,EAAE,aAAa,EAA8B,MAAM,YAAY,CAAC;AAG1F;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAuDG;AACH,eAAO,MAAM,YAAY,GAAI,QAAQ,SAAS,eAAe,EAC5D,QAAQ,YAAY,CAAC,QAAQ,CAAC,KAC5B,aAAa,CAAC,QAAQ,CAsBxB,CAAC"}
|