@xmachines/play-xstate 1.0.0-beta.8 → 1.0.0
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 +175 -336
- package/dist/define-player.d.ts +6 -56
- package/dist/define-player.d.ts.map +1 -1
- package/dist/define-player.js +7 -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 +170 -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 +16 -1
- package/dist/guards/helpers.d.ts.map +1 -1
- package/dist/guards/helpers.js +16 -35
- 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 +1 -1
- package/dist/guards/types.d.ts.map +1 -1
- package/dist/index.d.ts +6 -10
- 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 +134 -28
- package/dist/player-actor.d.ts.map +1 -1
- package/dist/player-actor.js +424 -159
- 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 +48 -25
- package/dist/routing/build-url.js.map +1 -1
- package/dist/routing/derive-current-route.d.ts +22 -0
- package/dist/routing/derive-current-route.d.ts.map +1 -0
- package/dist/routing/derive-current-route.js +122 -0
- package/dist/routing/derive-current-route.js.map +1 -0
- package/dist/routing/derive-initial-route.d.ts +43 -0
- package/dist/routing/derive-initial-route.d.ts.map +1 -0
- package/dist/routing/derive-initial-route.js +57 -0
- package/dist/routing/derive-initial-route.js.map +1 -0
- package/dist/routing/derive-route.d.ts +15 -1
- package/dist/routing/derive-route.d.ts.map +1 -1
- package/dist/routing/derive-route.js +25 -10
- package/dist/routing/derive-route.js.map +1 -1
- package/dist/routing/format-play-route-transitions.d.ts +29 -2
- package/dist/routing/format-play-route-transitions.d.ts.map +1 -1
- package/dist/routing/format-play-route-transitions.js +32 -8
- package/dist/routing/format-play-route-transitions.js.map +1 -1
- package/dist/routing/index.d.ts +3 -1
- package/dist/routing/index.d.ts.map +1 -1
- package/dist/routing/index.js +2 -0
- package/dist/routing/index.js.map +1 -1
- package/dist/routing/types.d.ts +11 -4
- package/dist/routing/types.d.ts.map +1 -1
- package/dist/types.d.ts +12 -6
- package/dist/types.d.ts.map +1 -1
- package/package.json +34 -11
- 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 -27
- 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 -22
- 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 -51
- package/dist/catalog/validate-props.d.ts.map +0 -1
- package/dist/catalog/validate-props.js +0 -155
- 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/dist/signals/index.d.ts +0 -2
- package/dist/signals/index.d.ts.map +0 -1
- package/dist/signals/index.js +0 -2
- package/dist/signals/index.js.map +0 -1
- package/dist/signals/state-signal.d.ts +0 -33
- package/dist/signals/state-signal.d.ts.map +0 -1
- package/dist/signals/state-signal.js +0 -41
- package/dist/signals/state-signal.js.map +0 -1
package/dist/player-actor.js
CHANGED
|
@@ -1,9 +1,8 @@
|
|
|
1
1
|
import { createActor, } from "xstate";
|
|
2
|
-
import { AbstractActor
|
|
2
|
+
import { AbstractActor } from "@xmachines/play-actor";
|
|
3
3
|
import { Signal } from "@xmachines/play-signals";
|
|
4
|
-
import {
|
|
5
|
-
import {
|
|
6
|
-
import { validateComponentBinding, validateViewProps, mergeViewProps, createMemoSignature, } from "./catalog/index.js";
|
|
4
|
+
import { InvalidEventError, InvalidMachineError } from "./errors.js";
|
|
5
|
+
import { deriveCurrentRoute, deriveInitialRoute } from "./routing/index.js";
|
|
7
6
|
const hasSnapshotStatus = (snapshot) => {
|
|
8
7
|
if (!snapshot || typeof snapshot !== "object") {
|
|
9
8
|
return false;
|
|
@@ -13,8 +12,236 @@ const hasSnapshotStatus = (snapshot) => {
|
|
|
13
12
|
}
|
|
14
13
|
return typeof snapshot.status === "string";
|
|
15
14
|
};
|
|
16
|
-
|
|
17
|
-
|
|
15
|
+
/**
|
|
16
|
+
* A snapshot worth propagating to signals: an active snapshot or a "done"
|
|
17
|
+
* snapshot (top-level final state reached). Error and stopped snapshots are
|
|
18
|
+
* skipped so signals keep the last observable state instead of surfacing
|
|
19
|
+
* teardown/error artifacts.
|
|
20
|
+
*/
|
|
21
|
+
const isObservableSnapshot = (snapshot) => {
|
|
22
|
+
return (hasSnapshotStatus(snapshot) && (snapshot.status === "active" || snapshot.status === "done"));
|
|
23
|
+
};
|
|
24
|
+
const resolveViewMeta = (meta) => {
|
|
25
|
+
// Iterate last-to-first: snapshot.getMeta() orders keys ancestors-first, so
|
|
26
|
+
// scanning from the end makes the deepest active state's meta.view win over
|
|
27
|
+
// an ancestor's instead of being shadowed by it.
|
|
28
|
+
const values = Object.values(meta);
|
|
29
|
+
for (let i = values.length - 1; i >= 0; i--) {
|
|
30
|
+
const stateMeta = values[i]; // nosemgrep: gitlab.eslint.detect-object-injection
|
|
31
|
+
const maybeView = stateMeta && typeof stateMeta === "object"
|
|
32
|
+
? stateMeta.view
|
|
33
|
+
: undefined;
|
|
34
|
+
if (maybeView &&
|
|
35
|
+
typeof maybeView === "object" &&
|
|
36
|
+
"root" in maybeView &&
|
|
37
|
+
"elements" in maybeView) {
|
|
38
|
+
return maybeView;
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
return null;
|
|
42
|
+
};
|
|
43
|
+
/**
|
|
44
|
+
* Merge route parameters and an explicit allowlist of context fields into a single
|
|
45
|
+
* element's props.
|
|
46
|
+
*
|
|
47
|
+
* Priority rule (highest → lowest):
|
|
48
|
+
* 1. Explicit non-`undefined` spec prop — always wins (static values are authoritative).
|
|
49
|
+
* 2. URL route params (`context.params`) — fills `undefined` slots from the URL path.
|
|
50
|
+
* 3. Allowlisted context fields (`contextProps`) — fills remaining `undefined` slots from
|
|
51
|
+
* the machine context (e.g. `context.username` on a state with no URL username param).
|
|
52
|
+
*
|
|
53
|
+
* The `contextProps` allowlist is the key safety mechanism: only fields the machine
|
|
54
|
+
* explicitly opts in to are ever exposed to components.
|
|
55
|
+
*
|
|
56
|
+
* @param urlParams - Extracted URL path parameters from `context.params`.
|
|
57
|
+
* @param contextValues - Object containing only the allowlisted context fields.
|
|
58
|
+
* @param existingProps - The element's props as declared in the machine spec.
|
|
59
|
+
* @returns Merged props object with URL params and allowlisted context values filling
|
|
60
|
+
* undefined slots.
|
|
61
|
+
*
|
|
62
|
+
* @example
|
|
63
|
+
* ```ts
|
|
64
|
+
* // spec: { section: undefined, username: undefined, title: "Dashboard" }
|
|
65
|
+
* // urlParams: { section: "profile" } ← from URL /:section
|
|
66
|
+
* // contextValues: { username: "alice" } ← from contextProps: ["username"]
|
|
67
|
+
* // result: { section: "profile", username: "alice", title: "Dashboard" }
|
|
68
|
+
* ```
|
|
69
|
+
*/
|
|
70
|
+
function mergeRouteParamsIntoProps(urlParams, contextValues, existingProps) {
|
|
71
|
+
// Layer 3 (lowest priority): allowlisted context values
|
|
72
|
+
const merged = { ...contextValues };
|
|
73
|
+
// Layer 2: URL params override context values for the same key
|
|
74
|
+
for (const [k, v] of Object.entries(urlParams)) {
|
|
75
|
+
merged[k] = v; // nosemgrep: gitlab.eslint.detect-object-injection
|
|
76
|
+
}
|
|
77
|
+
// Layer 1 (highest priority): explicit non-undefined spec props always win
|
|
78
|
+
for (const [k, v] of Object.entries(existingProps)) {
|
|
79
|
+
if (v !== undefined)
|
|
80
|
+
merged[k] = v; // nosemgrep: gitlab.eslint.detect-object-injection
|
|
81
|
+
}
|
|
82
|
+
return merged;
|
|
83
|
+
}
|
|
84
|
+
/**
|
|
85
|
+
* Compare two derived `PlaySpec` objects for rendered-view equivalence.
|
|
86
|
+
*
|
|
87
|
+
* `deriveCurrentView` returns a fresh object on every call, so reference equality
|
|
88
|
+
* cannot tell whether the view actually changed. This helper performs a cheap
|
|
89
|
+
* structural comparison over the inputs that determine the rendered output:
|
|
90
|
+
*
|
|
91
|
+
* - Top-level spec fields (`root`, `contextProps`, …) by identity — they are
|
|
92
|
+
* primitives or references copied unchanged from the machine's stable `meta.view`.
|
|
93
|
+
* - Per-element fields other than `props` by identity — same reasoning.
|
|
94
|
+
* - Element `props` by shallow `Object.is` per key — this is where URL params and
|
|
95
|
+
* allowlisted context values are merged in, so a param/context change that alters
|
|
96
|
+
* the rendered spec is detected here.
|
|
97
|
+
*
|
|
98
|
+
* Used by `PlayerActor.validateAndCacheView` to skip re-emitting the view signal
|
|
99
|
+
* for snapshots that do not change the rendered view (e.g. context-only assigns).
|
|
100
|
+
*/
|
|
101
|
+
const areViewSpecsEquivalent = (a, b) => {
|
|
102
|
+
if (a === b)
|
|
103
|
+
return true;
|
|
104
|
+
if (a === null || b === null)
|
|
105
|
+
return false;
|
|
106
|
+
const aRecord = a;
|
|
107
|
+
const bRecord = b;
|
|
108
|
+
const topKeys = Object.keys(aRecord);
|
|
109
|
+
if (topKeys.length !== Object.keys(bRecord).length)
|
|
110
|
+
return false;
|
|
111
|
+
for (const key of topKeys) {
|
|
112
|
+
if (key === "elements")
|
|
113
|
+
continue;
|
|
114
|
+
if (!Object.is(aRecord[key], bRecord[key]))
|
|
115
|
+
return false; // nosemgrep: gitlab.eslint.detect-object-injection
|
|
116
|
+
}
|
|
117
|
+
const aElements = (aRecord.elements ?? null);
|
|
118
|
+
const bElements = (bRecord.elements ?? null);
|
|
119
|
+
if (aElements === bElements)
|
|
120
|
+
return true;
|
|
121
|
+
if (aElements === null || bElements === null)
|
|
122
|
+
return false;
|
|
123
|
+
const elementKeys = Object.keys(aElements);
|
|
124
|
+
if (elementKeys.length !== Object.keys(bElements).length)
|
|
125
|
+
return false;
|
|
126
|
+
for (const key of elementKeys) {
|
|
127
|
+
const aElement = aElements[key]; // nosemgrep: gitlab.eslint.detect-object-injection
|
|
128
|
+
const bElement = bElements[key]; // nosemgrep: gitlab.eslint.detect-object-injection
|
|
129
|
+
if (aElement === bElement)
|
|
130
|
+
continue;
|
|
131
|
+
if (!aElement || !bElement)
|
|
132
|
+
return false;
|
|
133
|
+
const fieldKeys = Object.keys(aElement);
|
|
134
|
+
if (fieldKeys.length !== Object.keys(bElement).length)
|
|
135
|
+
return false;
|
|
136
|
+
for (const field of fieldKeys) {
|
|
137
|
+
if (field === "props")
|
|
138
|
+
continue;
|
|
139
|
+
if (!Object.is(aElement[field], bElement[field]))
|
|
140
|
+
return false; // nosemgrep: gitlab.eslint.detect-object-injection
|
|
141
|
+
}
|
|
142
|
+
const aProps = (aElement.props ?? {});
|
|
143
|
+
const bProps = (bElement.props ?? {});
|
|
144
|
+
const propKeys = Object.keys(aProps);
|
|
145
|
+
if (propKeys.length !== Object.keys(bProps).length)
|
|
146
|
+
return false;
|
|
147
|
+
for (const prop of propKeys) {
|
|
148
|
+
if (!Object.is(aProps[prop], bProps[prop]))
|
|
149
|
+
return false; // nosemgrep: gitlab.eslint.detect-object-injection
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
return true;
|
|
153
|
+
};
|
|
154
|
+
/**
|
|
155
|
+
* Derive the actor's current view from state metadata.
|
|
156
|
+
*
|
|
157
|
+
* Always returns a **fresh** `PlaySpec` object so a genuine view change is never
|
|
158
|
+
* suppressed by `Signal.State`'s `Object.is` equality check. Whether that fresh
|
|
159
|
+
* object is actually emitted is decided by `PlayerActor.validateAndCacheView`,
|
|
160
|
+
* which compares against the last emitted spec (see {@link areViewSpecsEquivalent})
|
|
161
|
+
* and skips emission when the rendered view is unchanged — preventing downstream
|
|
162
|
+
* providers from remounting the UI on every event.
|
|
163
|
+
*
|
|
164
|
+
* ### Prop enrichment
|
|
165
|
+
*
|
|
166
|
+
* Each spec element's `props` are enriched before the view is emitted using two sources:
|
|
167
|
+
*
|
|
168
|
+
* **1. URL route params** (`context.params`, populated by `formatPlayRouteTransitions`):
|
|
169
|
+
* Makes `:section?` / `:username` URL path parameters automatically available as component
|
|
170
|
+
* props without manual wiring.
|
|
171
|
+
*
|
|
172
|
+
* **2. Context fields** (`spec.contextProps` allowlist):
|
|
173
|
+
* Exposes selected context fields as component props for states where no URL param covers
|
|
174
|
+
* the data (e.g. `context.username` on a `/dashboard` route). Only fields explicitly named
|
|
175
|
+
* in `spec.contextProps: string[]` are ever exposed — nothing leaks from context implicitly.
|
|
176
|
+
*
|
|
177
|
+
* Merge priority (see `mergeRouteParamsIntoProps`):
|
|
178
|
+
* 1. Explicit non-`undefined` spec prop — always wins (authoritative static value).
|
|
179
|
+
* 2. URL route param (`context.params`) — fills `undefined` slots from the URL path.
|
|
180
|
+
* 3. Allowlisted context field (`contextProps`) — fills remaining `undefined` slots.
|
|
181
|
+
*
|
|
182
|
+
* @param snapshot - Current XState machine snapshot.
|
|
183
|
+
* @returns Enriched `PlaySpec`, or `null` if the current state has no view metadata.
|
|
184
|
+
*
|
|
185
|
+
* @example
|
|
186
|
+
* ```ts
|
|
187
|
+
* // spec: { contextProps: ["username"], elements: { root: { props: { username: undefined } } } }
|
|
188
|
+
* // context.username = "alice", context.params = {}
|
|
189
|
+
* // Derived props: { username: "alice" }
|
|
190
|
+
*
|
|
191
|
+
* // spec: { contextProps: ["username"], elements: { root: { props: { username: undefined } } } }
|
|
192
|
+
* // context.username = "alice", context.params = { username: "demo" }
|
|
193
|
+
* // Derived props: { username: "demo" } ← URL param wins
|
|
194
|
+
* ```
|
|
195
|
+
*/
|
|
196
|
+
const deriveCurrentView = (snapshot) => {
|
|
197
|
+
if (!snapshot) {
|
|
198
|
+
return null;
|
|
199
|
+
}
|
|
200
|
+
const meta = snapshot.getMeta();
|
|
201
|
+
if (!meta || typeof meta !== "object") {
|
|
202
|
+
return null;
|
|
203
|
+
}
|
|
204
|
+
const viewMeta = resolveViewMeta(meta);
|
|
205
|
+
if (!viewMeta) {
|
|
206
|
+
return null;
|
|
207
|
+
}
|
|
208
|
+
// Extract params from context (set by formatPlayRouteTransitions on play.route events)
|
|
209
|
+
const context = snapshot.context !== null &&
|
|
210
|
+
snapshot.context !== undefined &&
|
|
211
|
+
typeof snapshot.context === "object"
|
|
212
|
+
? snapshot.context
|
|
213
|
+
: null;
|
|
214
|
+
const urlParams = context !== null && typeof context.params === "object" && context.params !== null
|
|
215
|
+
? context.params
|
|
216
|
+
: {};
|
|
217
|
+
// Extract the allowlisted context fields declared in contextProps.
|
|
218
|
+
// Only fields explicitly named in the allowlist are ever exposed to components.
|
|
219
|
+
const contextPropsAllowlist = viewMeta?.contextProps ?? [];
|
|
220
|
+
const contextValues = {};
|
|
221
|
+
if (context !== null && contextPropsAllowlist.length > 0) {
|
|
222
|
+
for (const key of contextPropsAllowlist) {
|
|
223
|
+
// nosemgrep: gitlab.eslint.detect-object-injection
|
|
224
|
+
if (key in context && context[key] !== null && context[key] !== undefined) {
|
|
225
|
+
contextValues[key] = context[key]; // nosemgrep: gitlab.eslint.detect-object-injection
|
|
226
|
+
}
|
|
227
|
+
}
|
|
228
|
+
}
|
|
229
|
+
// Enrich element props when there is anything to merge.
|
|
230
|
+
if (viewMeta?.elements) {
|
|
231
|
+
const enrichedElements = Object.fromEntries(Object.entries(viewMeta.elements).map(([key, el]) => {
|
|
232
|
+
const element = el;
|
|
233
|
+
const existingProps = element.props ?? {};
|
|
234
|
+
return [
|
|
235
|
+
key,
|
|
236
|
+
{
|
|
237
|
+
...element,
|
|
238
|
+
props: mergeRouteParamsIntoProps(urlParams, contextValues, existingProps),
|
|
239
|
+
},
|
|
240
|
+
];
|
|
241
|
+
}));
|
|
242
|
+
return { ...viewMeta, elements: enrichedElements };
|
|
243
|
+
}
|
|
244
|
+
return viewMeta;
|
|
18
245
|
};
|
|
19
246
|
/**
|
|
20
247
|
* Concrete XState actor implementing Play Architecture signal protocol
|
|
@@ -60,7 +287,7 @@ const isActiveSnapshot = (snapshot) => {
|
|
|
60
287
|
* ```
|
|
61
288
|
*
|
|
62
289
|
* @example
|
|
63
|
-
* Signal lifecycle with watchers
|
|
290
|
+
* Signal lifecycle with watchers
|
|
64
291
|
* ```typescript
|
|
65
292
|
* import { Signal } from "@xmachines/play-signals";
|
|
66
293
|
*
|
|
@@ -73,10 +300,10 @@ const isActiveSnapshot = (snapshot) => {
|
|
|
73
300
|
*
|
|
74
301
|
* watcher.watch(actor.state);
|
|
75
302
|
* actor.send({ type: 'play.route', to: '#about' });
|
|
76
|
-
* // Watcher notification scheduled via microtask
|
|
303
|
+
* // Watcher notification scheduled via microtask by the watcher itself
|
|
77
304
|
* ```
|
|
78
305
|
*
|
|
79
|
-
* @see
|
|
306
|
+
* @see [Play RFC](../../docs/rfc/play.md)
|
|
80
307
|
* @see {@link definePlayer} for factory creation
|
|
81
308
|
* @see {@link @xmachines/play-actor!AbstractActor} for signal protocol
|
|
82
309
|
* @see {@link @xmachines/play-actor!Routable} for routing capability
|
|
@@ -92,75 +319,147 @@ const isActiveSnapshot = (snapshot) => {
|
|
|
92
319
|
* cached and updated at state entry, not computed on every read.
|
|
93
320
|
*/
|
|
94
321
|
export class PlayerActor extends AbstractActor {
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
_lastViewMemo;
|
|
322
|
+
xstateActor;
|
|
323
|
+
playerOptions;
|
|
324
|
+
viewSignal;
|
|
325
|
+
/** Last spec emitted on the view signal — used to skip no-change re-emissions. */
|
|
326
|
+
lastEmittedView = null;
|
|
101
327
|
// AbstractActor protocol requirements
|
|
102
328
|
state;
|
|
329
|
+
/**
|
|
330
|
+
* Returns whether the actor's current state can accept the given event.
|
|
331
|
+
*
|
|
332
|
+
* Typed to the machine's event union — passing an unknown event type is a
|
|
333
|
+
* compile error. Delegates to the underlying XState actor's snapshot.
|
|
334
|
+
*
|
|
335
|
+
* @example
|
|
336
|
+
* ```typescript
|
|
337
|
+
* if (actor.can({ type: "auth.logout" })) { ... }
|
|
338
|
+
* ```
|
|
339
|
+
*/
|
|
340
|
+
can(event) {
|
|
341
|
+
return this.state.get().can(event);
|
|
342
|
+
}
|
|
343
|
+
/**
|
|
344
|
+
* A TC39 `Signal.Computed` that derives the current URL path from the active
|
|
345
|
+
* machine state's `meta.route` template and the actor's context.
|
|
346
|
+
*
|
|
347
|
+
* Returns `null` when the current state has no `meta.route`, or when the route
|
|
348
|
+
* template cannot be fully resolved (e.g. a required parameter is absent from
|
|
349
|
+
* context).
|
|
350
|
+
*
|
|
351
|
+
* @throws {MissingRouteParamError} When a required `:param` placeholder in the
|
|
352
|
+
* route template has no matching value in the actor's context. Import the class
|
|
353
|
+
* from `@xmachines/play-xstate/errors`.
|
|
354
|
+
*
|
|
355
|
+
* @example
|
|
356
|
+
* ```typescript
|
|
357
|
+
* // Returns "/profile/alice" when context.userId === "alice"
|
|
358
|
+
* const route = actor.currentRoute.get();
|
|
359
|
+
* ```
|
|
360
|
+
*/
|
|
103
361
|
currentRoute;
|
|
362
|
+
/**
|
|
363
|
+
* The route derived from the machine's initial state — fixed at construction,
|
|
364
|
+
* never changes even when the actor is restored from a snapshot.
|
|
365
|
+
*
|
|
366
|
+
* Router bridges compare this against the browser URL to distinguish a deep-link
|
|
367
|
+
* (non-initial URL → router wins) from a restore (initial URL + actor at a
|
|
368
|
+
* different restored route → actor wins).
|
|
369
|
+
*
|
|
370
|
+
* Derived statically from the machine definition via `deriveInitialRoute`
|
|
371
|
+
* (XState's pure `initialTransition` helper): the initial state chain and its
|
|
372
|
+
* `meta.route` templates are fixed at machine definition time, while `:param`
|
|
373
|
+
* substitution uses the machine's real initial context for this actor's `input`.
|
|
374
|
+
* No extra actor is ever created, and a restored snapshot never influences the
|
|
375
|
+
* value — it is always the machine's **default** initial route.
|
|
376
|
+
*/
|
|
377
|
+
initialRoute;
|
|
378
|
+
/**
|
|
379
|
+
* Reactive signal containing the current view spec derived from the active state's
|
|
380
|
+
* `meta.view` metadata.
|
|
381
|
+
*
|
|
382
|
+
* Emits a **fresh object reference** whenever the rendered view actually changes —
|
|
383
|
+
* a different state's view, or a param/context change that alters the resolved
|
|
384
|
+
* spec (including `reenter: true` re-entries with new params). Snapshots that do
|
|
385
|
+
* not change the rendered view (e.g. context-only assigns) keep the previous
|
|
386
|
+
* reference so downstream providers do not remount the UI on every event.
|
|
387
|
+
*
|
|
388
|
+
* The emitted `PlaySpec` has its element `props` enriched with `context.params`
|
|
389
|
+
* before emission — URL path parameters (e.g. `:section?`) flow into component props
|
|
390
|
+
* automatically. See `mergeRouteParamsIntoProps` for the merge priority rules.
|
|
391
|
+
*
|
|
392
|
+
* Returns `null` when the current state has no `meta.view` metadata.
|
|
393
|
+
*
|
|
394
|
+
* @example
|
|
395
|
+
* ```typescript
|
|
396
|
+
* const view = actor.currentView.get();
|
|
397
|
+
* if (view) {
|
|
398
|
+
* console.log(view.root); // e.g. "root"
|
|
399
|
+
* console.log(view.elements); // @xmachines/json-render-core Spec elements
|
|
400
|
+
* }
|
|
401
|
+
* ```
|
|
402
|
+
*/
|
|
104
403
|
currentView;
|
|
105
|
-
|
|
106
|
-
constructor(machine, catalog, options, input) {
|
|
404
|
+
constructor(machine, options, input, restoredSnapshot) {
|
|
107
405
|
// Defensive check: Validate machine before passing to createActor
|
|
108
406
|
if (!machine || typeof machine !== "object") {
|
|
109
|
-
throw new
|
|
407
|
+
throw new InvalidMachineError();
|
|
110
408
|
}
|
|
111
409
|
// Create XState actor
|
|
112
|
-
|
|
113
|
-
//
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
410
|
+
// XState 5.28.0: createActor() options has a conditional type constraint on `input` that
|
|
411
|
+
// TypeScript cannot resolve against an unbound generic TMachine parameter.
|
|
412
|
+
// Option B: Cast to ActorOptions<TMachine> — narrower than `as any` because we remain
|
|
413
|
+
// within the XState type system. The `input` parameter is typed as InputFrom<TMachine>
|
|
414
|
+
// at the constructor call site, so consumers receive compile-time validation.
|
|
415
|
+
// Track XState typing improvements: https://github.com/statelyai/xstate/issues
|
|
416
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
417
|
+
const xstateActor = createActor(machine, {
|
|
418
|
+
input,
|
|
419
|
+
snapshot: restoredSnapshot,
|
|
420
|
+
});
|
|
117
421
|
// Call AbstractActor constructor with actor logic
|
|
118
422
|
super(xstateActor.logic);
|
|
119
|
-
|
|
120
|
-
this
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
//
|
|
124
|
-
this.
|
|
125
|
-
this.
|
|
423
|
+
// Derive the machine's initial route for restore-vs-deeplink detection.
|
|
424
|
+
// Statically computed from the machine definition + this actor's input via
|
|
425
|
+
// XState's pure `initialTransition` helper — no extra actor, and independent
|
|
426
|
+
// of any restored snapshot, so bridges always see the machine's DEFAULT
|
|
427
|
+
// initial route rather than the restored state's.
|
|
428
|
+
this.initialRoute = deriveInitialRoute(machine, input);
|
|
429
|
+
this.xstateActor = xstateActor;
|
|
430
|
+
this.playerOptions = options || {};
|
|
431
|
+
// Initialize state signal. Updates are synchronous (no microtask batching):
|
|
432
|
+
// XState already coalesces multiple transitions within a single send() into
|
|
433
|
+
// one subscription callback, and synchronous updates ensure guard-triggered
|
|
434
|
+
// redirects are immediately visible to router bridges.
|
|
435
|
+
this.state = new Signal.State(xstateActor.getSnapshot());
|
|
126
436
|
// Initialize view signal
|
|
127
|
-
this.
|
|
128
|
-
this._lastViewMemo = null;
|
|
437
|
+
this.viewSignal = new Signal.State(null);
|
|
129
438
|
// Initialize currentRoute computed signal
|
|
130
439
|
this.currentRoute = new Signal.Computed(() => {
|
|
131
440
|
const snapshot = this.state.get();
|
|
132
|
-
|
|
133
|
-
if (!snapshot || typeof snapshot.getMeta !== "function") {
|
|
134
|
-
return null;
|
|
135
|
-
}
|
|
136
|
-
const meta = snapshot.getMeta();
|
|
137
|
-
// Defensive check: Validate meta is an object
|
|
138
|
-
if (!meta || typeof meta !== "object") {
|
|
139
|
-
return null;
|
|
140
|
-
}
|
|
141
|
-
// Derive route from meta.route per Plan 04
|
|
142
|
-
const routeTemplate = deriveRoute(meta);
|
|
143
|
-
if (!routeTemplate) {
|
|
144
|
-
return null;
|
|
145
|
-
}
|
|
146
|
-
// Build full URL with params, query, hash
|
|
147
|
-
// Per CONTEXT.md: "Full URL generation including query params, hash, base path from context"
|
|
148
|
-
return buildRouteUrl(routeTemplate, snapshot.context ?? {});
|
|
441
|
+
return deriveCurrentRoute(snapshot);
|
|
149
442
|
});
|
|
150
443
|
// Expose view signal directly for proper watcher propagation
|
|
151
|
-
this.currentView = this.
|
|
444
|
+
this.currentView = this.viewSignal;
|
|
152
445
|
// Subscribe to XState actor transitions
|
|
153
|
-
this.
|
|
154
|
-
// Only update on stable
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
//
|
|
159
|
-
|
|
160
|
-
|
|
446
|
+
this.xstateActor.subscribe((snapshot) => {
|
|
447
|
+
// Only update on stable states per CONTEXT.md: active snapshots and the
|
|
448
|
+
// "done" snapshot from a top-level final state. Error/stopped snapshots
|
|
449
|
+
// are skipped so signals never freeze on teardown artifacts.
|
|
450
|
+
if (isObservableSnapshot(snapshot)) {
|
|
451
|
+
// State updates are synchronous so router bridges see guard redirects immediately.
|
|
452
|
+
this.state.set(snapshot);
|
|
453
|
+
// Validate and cache the view after state/currentRoute are current, before hooks.
|
|
454
|
+
// Hook ordering is intentional:
|
|
455
|
+
// 1. state/currentRoute updated
|
|
456
|
+
// 2. currentView cached
|
|
457
|
+
// 3. onStateChange hook
|
|
458
|
+
// 4. send() then invokes onTransition
|
|
459
|
+
this.validateAndCacheView(snapshot);
|
|
161
460
|
// Call onStateChange hook
|
|
162
|
-
if (this.
|
|
163
|
-
this.
|
|
461
|
+
if (this.playerOptions.onStateChange) {
|
|
462
|
+
this.playerOptions.onStateChange(this, snapshot);
|
|
164
463
|
}
|
|
165
464
|
}
|
|
166
465
|
});
|
|
@@ -171,10 +470,10 @@ export class PlayerActor extends AbstractActor {
|
|
|
171
470
|
* Per RESEARCH.md Pitfall 1: Always call start() after creation
|
|
172
471
|
*/
|
|
173
472
|
start() {
|
|
174
|
-
this.
|
|
473
|
+
this.xstateActor.start();
|
|
175
474
|
// Call onStart hook
|
|
176
|
-
if (this.
|
|
177
|
-
this.
|
|
475
|
+
if (this.playerOptions.onStart) {
|
|
476
|
+
this.playerOptions.onStart(this);
|
|
178
477
|
}
|
|
179
478
|
return this;
|
|
180
479
|
}
|
|
@@ -182,54 +481,78 @@ export class PlayerActor extends AbstractActor {
|
|
|
182
481
|
* Stop the actor and cleanup
|
|
183
482
|
*/
|
|
184
483
|
stop() {
|
|
185
|
-
this.
|
|
186
|
-
this._stateManager.dispose();
|
|
484
|
+
this.xstateActor.stop();
|
|
187
485
|
// Call onStop hook
|
|
188
|
-
if (this.
|
|
189
|
-
this.
|
|
486
|
+
if (this.playerOptions.onStop) {
|
|
487
|
+
this.playerOptions.onStop(this);
|
|
190
488
|
}
|
|
191
489
|
return this;
|
|
192
490
|
}
|
|
193
491
|
/**
|
|
194
|
-
* Send event to actor
|
|
492
|
+
* Send an event to the underlying XState actor.
|
|
195
493
|
*
|
|
196
|
-
*
|
|
197
|
-
*
|
|
494
|
+
* The actor's state machine guards decide whether the event causes a transition.
|
|
495
|
+
* Pass any event from the machine's event union — domain events, routing events, etc.
|
|
198
496
|
*
|
|
199
|
-
* @param event -
|
|
497
|
+
* @param event - An event from the machine's `EventFromLogic<TMachine>` union.
|
|
498
|
+
*
|
|
499
|
+
* @throws {InvalidEventError} When `event` is not a plain object (`null`, `undefined`,
|
|
500
|
+
* a string, number, etc.). Import the class from `@xmachines/play-xstate/errors`.
|
|
200
501
|
*
|
|
201
502
|
* @example
|
|
202
503
|
* ```typescript
|
|
203
|
-
* // Domain event
|
|
204
|
-
* actor.send({ type:
|
|
504
|
+
* // Domain event (typed to machine's event union)
|
|
505
|
+
* actor.send({ type: "auth.login", userId: "123" });
|
|
205
506
|
*
|
|
206
507
|
* // Routing event
|
|
207
|
-
* actor.send({ type:
|
|
508
|
+
* actor.send({ type: "play.route", to: "#home" });
|
|
208
509
|
* ```
|
|
209
510
|
*/
|
|
210
511
|
send(event) {
|
|
211
512
|
// Defensive check: Validate event is not null/undefined
|
|
212
513
|
if (!event || typeof event !== "object") {
|
|
213
|
-
|
|
214
|
-
return;
|
|
514
|
+
throw new InvalidEventError(event);
|
|
215
515
|
}
|
|
216
516
|
// Store previous state for onTransition hook
|
|
217
|
-
const prevSnapshot = this.
|
|
517
|
+
const prevSnapshot = this.xstateActor.getSnapshot();
|
|
218
518
|
// Send to XState actor
|
|
219
|
-
// EventFromLogic<TMachine> is
|
|
220
|
-
|
|
221
|
-
this._xstateActor.send(event);
|
|
519
|
+
// EventFromLogic<TMachine> is exactly what xstateActor.send expects — no cast needed.
|
|
520
|
+
this.xstateActor.send(event);
|
|
222
521
|
// Call onTransition hook
|
|
223
|
-
if (this.
|
|
224
|
-
const nextSnapshot = this.
|
|
225
|
-
this.
|
|
522
|
+
if (this.playerOptions.onTransition) {
|
|
523
|
+
const nextSnapshot = this.xstateActor.getSnapshot();
|
|
524
|
+
this.playerOptions.onTransition(this, prevSnapshot, nextSnapshot);
|
|
226
525
|
}
|
|
227
526
|
}
|
|
228
527
|
/**
|
|
229
528
|
* Get current snapshot
|
|
230
529
|
*/
|
|
231
530
|
getSnapshot() {
|
|
232
|
-
return this.
|
|
531
|
+
return this.xstateActor.getSnapshot();
|
|
532
|
+
}
|
|
533
|
+
subscribe(nextListenerOrObserver, errorListener, completeListener) {
|
|
534
|
+
// XState's subscribe() normalizes function-or-observer internally; the cast
|
|
535
|
+
// only reconciles the overload signatures.
|
|
536
|
+
return this.xstateActor.subscribe(nextListenerOrObserver, errorListener, completeListener);
|
|
537
|
+
}
|
|
538
|
+
/**
|
|
539
|
+
* Listen for events emitted by the wrapped XState actor via the `emit` action.
|
|
540
|
+
*
|
|
541
|
+
* @param type - Emitted event type to listen for, or `"*"` for all.
|
|
542
|
+
* @param handler - Called with each matching emitted event.
|
|
543
|
+
* @returns Subscription with an `unsubscribe()` method.
|
|
544
|
+
*/
|
|
545
|
+
on(type, handler) {
|
|
546
|
+
return this.xstateActor.on(type, handler);
|
|
547
|
+
}
|
|
548
|
+
/**
|
|
549
|
+
* Get the persisted snapshot of the wrapped XState actor.
|
|
550
|
+
*
|
|
551
|
+
* Suitable for serialization and later restoration via the factory's
|
|
552
|
+
* `restore.snapshot` option.
|
|
553
|
+
*/
|
|
554
|
+
getPersistedSnapshot() {
|
|
555
|
+
return this.xstateActor.getPersistedSnapshot();
|
|
233
556
|
}
|
|
234
557
|
/**
|
|
235
558
|
* Validate view at state entry and cache result
|
|
@@ -239,86 +562,28 @@ export class PlayerActor extends AbstractActor {
|
|
|
239
562
|
*
|
|
240
563
|
* @param snapshot - Current XState snapshot
|
|
241
564
|
*/
|
|
242
|
-
|
|
243
|
-
// Defensive check: Validate snapshot exists
|
|
244
|
-
if (!snapshot) {
|
|
245
|
-
this._lastViewMemo = null;
|
|
246
|
-
this._viewSignal.set(null);
|
|
247
|
-
return;
|
|
248
|
-
}
|
|
249
|
-
const meta = snapshot.getMeta();
|
|
250
|
-
// Defensive check: Validate meta is an object
|
|
251
|
-
if (!meta || typeof meta !== "object") {
|
|
252
|
-
this._lastViewMemo = null;
|
|
253
|
-
this._viewSignal.set(null);
|
|
254
|
-
return;
|
|
255
|
-
}
|
|
256
|
-
// Find meta.view in active state nodes
|
|
257
|
-
let viewMeta = null;
|
|
258
|
-
for (const [_stateId, stateMeta] of Object.entries(meta)) {
|
|
259
|
-
const maybeView = stateMeta && typeof stateMeta === "object"
|
|
260
|
-
? stateMeta.view
|
|
261
|
-
: undefined;
|
|
262
|
-
if (maybeView && typeof maybeView === "object") {
|
|
263
|
-
viewMeta = maybeView;
|
|
264
|
-
break;
|
|
265
|
-
}
|
|
266
|
-
}
|
|
267
|
-
if (!viewMeta) {
|
|
268
|
-
this._lastViewMemo = null;
|
|
269
|
-
this._viewSignal.set(null);
|
|
270
|
-
return;
|
|
271
|
-
}
|
|
272
|
-
// Validate component binding
|
|
565
|
+
validateAndCacheView(snapshot) {
|
|
273
566
|
try {
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
//
|
|
278
|
-
|
|
279
|
-
|
|
567
|
+
const view = deriveCurrentView(snapshot);
|
|
568
|
+
// Emit only when the rendered view actually changed. deriveCurrentView
|
|
569
|
+
// returns a fresh object on every call (so genuine changes always pass
|
|
570
|
+
// Signal.State's Object.is gate), but re-emitting a fresh reference for
|
|
571
|
+
// snapshots that do not alter the view (e.g. context-only assigns) would
|
|
572
|
+
// make downstream providers treat every event as a view transition and
|
|
573
|
+
// remount the UI, wiping in-view state.
|
|
574
|
+
if (areViewSpecsEquivalent(this.lastEmittedView, view)) {
|
|
575
|
+
return;
|
|
280
576
|
}
|
|
281
|
-
|
|
282
|
-
this.
|
|
283
|
-
return;
|
|
284
|
-
}
|
|
285
|
-
// Merge meta.view with context for props
|
|
286
|
-
const props = mergeViewProps(viewMeta, snapshot.context);
|
|
287
|
-
const stateSignature = createMemoSignature(snapshot.value);
|
|
288
|
-
const propsSignature = createMemoSignature(props);
|
|
289
|
-
if (this._lastViewMemo &&
|
|
290
|
-
this._lastViewMemo.stateSignature === stateSignature &&
|
|
291
|
-
this._lastViewMemo.component === viewMeta.component &&
|
|
292
|
-
this._lastViewMemo.propsSignature === propsSignature) {
|
|
293
|
-
this._viewSignal.set({
|
|
294
|
-
component: this._lastViewMemo.view.component,
|
|
295
|
-
props: this._lastViewMemo.view.props,
|
|
296
|
-
});
|
|
297
|
-
return;
|
|
577
|
+
this.lastEmittedView = view;
|
|
578
|
+
this.viewSignal.set(view);
|
|
298
579
|
}
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
if (this._options.onError) {
|
|
304
|
-
this._options.onError(this, new Error(`Invalid props for component "${viewMeta.component}": ${validation.error?.message || "Unknown error"}`));
|
|
580
|
+
catch (error) {
|
|
581
|
+
if (this.playerOptions.onError) {
|
|
582
|
+
const err = error instanceof Error ? error : new Error(String(error));
|
|
583
|
+
this.playerOptions.onError(this, err);
|
|
305
584
|
}
|
|
306
|
-
|
|
307
|
-
// Store view structure even if validation fails (allow app to handle gracefully)
|
|
308
|
-
// Per RESEARCH.md open question: prefer error events over throwing in production
|
|
585
|
+
// On error: keep the last valid view (don't clear)
|
|
309
586
|
}
|
|
310
|
-
// Cache validated view structure (validated once at state entry)
|
|
311
|
-
const newView = {
|
|
312
|
-
component: viewMeta.component,
|
|
313
|
-
props: validation.success ? validation.data : props,
|
|
314
|
-
};
|
|
315
|
-
this._lastViewMemo = {
|
|
316
|
-
stateSignature,
|
|
317
|
-
component: viewMeta.component,
|
|
318
|
-
propsSignature,
|
|
319
|
-
view: newView,
|
|
320
|
-
};
|
|
321
|
-
this._viewSignal.set(newView);
|
|
322
587
|
}
|
|
323
588
|
/**
|
|
324
589
|
* Convenience dispose method for cleanup
|