@xmachines/play-xstate 1.0.0-beta.9 → 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 +171 -338
- 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 +9 -2
- package/dist/guards/compose.d.ts.map +1 -1
- package/dist/guards/compose.js +3 -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 -27
- package/dist/player-actor.d.ts.map +1 -1
- package/dist/player-actor.js +417 -166
- package/dist/player-actor.js.map +1 -1
- package/dist/player-actor.typecheck.js +5 -3
- 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 -16
- 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 -66
- package/dist/catalog/validate-props.d.ts.map +0 -1
- package/dist/catalog/validate-props.js +0 -135
- 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,21 +319,92 @@ 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
410
|
// XState 5.28.0: createActor() options has a conditional type constraint on `input` that
|
|
@@ -116,58 +414,52 @@ export class PlayerActor extends AbstractActor {
|
|
|
116
414
|
// at the constructor call site, so consumers receive compile-time validation.
|
|
117
415
|
// Track XState typing improvements: https://github.com/statelyai/xstate/issues
|
|
118
416
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
119
|
-
const xstateActor = createActor(machine, {
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
}
|
|
417
|
+
const xstateActor = createActor(machine, {
|
|
418
|
+
input,
|
|
419
|
+
snapshot: restoredSnapshot,
|
|
420
|
+
});
|
|
124
421
|
// Call AbstractActor constructor with actor logic
|
|
125
422
|
super(xstateActor.logic);
|
|
126
|
-
|
|
127
|
-
this
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
//
|
|
131
|
-
this.
|
|
132
|
-
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());
|
|
133
436
|
// Initialize view signal
|
|
134
|
-
this.
|
|
135
|
-
this._lastViewMemo = null;
|
|
437
|
+
this.viewSignal = new Signal.State(null);
|
|
136
438
|
// Initialize currentRoute computed signal
|
|
137
439
|
this.currentRoute = new Signal.Computed(() => {
|
|
138
440
|
const snapshot = this.state.get();
|
|
139
|
-
|
|
140
|
-
if (!snapshot || typeof snapshot.getMeta !== "function") {
|
|
141
|
-
return null;
|
|
142
|
-
}
|
|
143
|
-
const meta = snapshot.getMeta();
|
|
144
|
-
// Defensive check: Validate meta is an object
|
|
145
|
-
if (!meta || typeof meta !== "object") {
|
|
146
|
-
return null;
|
|
147
|
-
}
|
|
148
|
-
// Derive route from meta.route per Plan 04
|
|
149
|
-
const routeTemplate = deriveRoute(meta);
|
|
150
|
-
if (!routeTemplate) {
|
|
151
|
-
return null;
|
|
152
|
-
}
|
|
153
|
-
// Build full URL with params, query, hash
|
|
154
|
-
// Per CONTEXT.md: "Full URL generation including query params, hash, base path from context"
|
|
155
|
-
return buildRouteUrl(routeTemplate, snapshot.context ?? {});
|
|
441
|
+
return deriveCurrentRoute(snapshot);
|
|
156
442
|
});
|
|
157
443
|
// Expose view signal directly for proper watcher propagation
|
|
158
|
-
this.currentView = this.
|
|
444
|
+
this.currentView = this.viewSignal;
|
|
159
445
|
// Subscribe to XState actor transitions
|
|
160
|
-
this.
|
|
161
|
-
// Only update on stable
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
//
|
|
166
|
-
|
|
167
|
-
|
|
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);
|
|
168
460
|
// Call onStateChange hook
|
|
169
|
-
if (this.
|
|
170
|
-
this.
|
|
461
|
+
if (this.playerOptions.onStateChange) {
|
|
462
|
+
this.playerOptions.onStateChange(this, snapshot);
|
|
171
463
|
}
|
|
172
464
|
}
|
|
173
465
|
});
|
|
@@ -178,10 +470,10 @@ export class PlayerActor extends AbstractActor {
|
|
|
178
470
|
* Per RESEARCH.md Pitfall 1: Always call start() after creation
|
|
179
471
|
*/
|
|
180
472
|
start() {
|
|
181
|
-
this.
|
|
473
|
+
this.xstateActor.start();
|
|
182
474
|
// Call onStart hook
|
|
183
|
-
if (this.
|
|
184
|
-
this.
|
|
475
|
+
if (this.playerOptions.onStart) {
|
|
476
|
+
this.playerOptions.onStart(this);
|
|
185
477
|
}
|
|
186
478
|
return this;
|
|
187
479
|
}
|
|
@@ -189,54 +481,78 @@ export class PlayerActor extends AbstractActor {
|
|
|
189
481
|
* Stop the actor and cleanup
|
|
190
482
|
*/
|
|
191
483
|
stop() {
|
|
192
|
-
this.
|
|
193
|
-
this._stateManager.dispose();
|
|
484
|
+
this.xstateActor.stop();
|
|
194
485
|
// Call onStop hook
|
|
195
|
-
if (this.
|
|
196
|
-
this.
|
|
486
|
+
if (this.playerOptions.onStop) {
|
|
487
|
+
this.playerOptions.onStop(this);
|
|
197
488
|
}
|
|
198
489
|
return this;
|
|
199
490
|
}
|
|
200
491
|
/**
|
|
201
|
-
* Send event to actor
|
|
492
|
+
* Send an event to the underlying XState actor.
|
|
202
493
|
*
|
|
203
|
-
*
|
|
204
|
-
*
|
|
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.
|
|
205
496
|
*
|
|
206
|
-
* @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`.
|
|
207
501
|
*
|
|
208
502
|
* @example
|
|
209
503
|
* ```typescript
|
|
210
|
-
* // Domain event
|
|
211
|
-
* actor.send({ type:
|
|
504
|
+
* // Domain event (typed to machine's event union)
|
|
505
|
+
* actor.send({ type: "auth.login", userId: "123" });
|
|
212
506
|
*
|
|
213
507
|
* // Routing event
|
|
214
|
-
* actor.send({ type:
|
|
508
|
+
* actor.send({ type: "play.route", to: "#home" });
|
|
215
509
|
* ```
|
|
216
510
|
*/
|
|
217
511
|
send(event) {
|
|
218
512
|
// Defensive check: Validate event is not null/undefined
|
|
219
513
|
if (!event || typeof event !== "object") {
|
|
220
|
-
|
|
221
|
-
return;
|
|
514
|
+
throw new InvalidEventError(event);
|
|
222
515
|
}
|
|
223
516
|
// Store previous state for onTransition hook
|
|
224
|
-
const prevSnapshot = this.
|
|
517
|
+
const prevSnapshot = this.xstateActor.getSnapshot();
|
|
225
518
|
// Send to XState actor
|
|
226
|
-
// EventFromLogic<TMachine> is
|
|
227
|
-
|
|
228
|
-
this._xstateActor.send(event);
|
|
519
|
+
// EventFromLogic<TMachine> is exactly what xstateActor.send expects — no cast needed.
|
|
520
|
+
this.xstateActor.send(event);
|
|
229
521
|
// Call onTransition hook
|
|
230
|
-
if (this.
|
|
231
|
-
const nextSnapshot = this.
|
|
232
|
-
this.
|
|
522
|
+
if (this.playerOptions.onTransition) {
|
|
523
|
+
const nextSnapshot = this.xstateActor.getSnapshot();
|
|
524
|
+
this.playerOptions.onTransition(this, prevSnapshot, nextSnapshot);
|
|
233
525
|
}
|
|
234
526
|
}
|
|
235
527
|
/**
|
|
236
528
|
* Get current snapshot
|
|
237
529
|
*/
|
|
238
530
|
getSnapshot() {
|
|
239
|
-
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();
|
|
240
556
|
}
|
|
241
557
|
/**
|
|
242
558
|
* Validate view at state entry and cache result
|
|
@@ -246,93 +562,28 @@ export class PlayerActor extends AbstractActor {
|
|
|
246
562
|
*
|
|
247
563
|
* @param snapshot - Current XState snapshot
|
|
248
564
|
*/
|
|
249
|
-
|
|
250
|
-
// Defensive check: Validate snapshot exists
|
|
251
|
-
if (!snapshot) {
|
|
252
|
-
this._lastViewMemo = null;
|
|
253
|
-
this._viewSignal.set(null);
|
|
254
|
-
return;
|
|
255
|
-
}
|
|
256
|
-
const meta = snapshot.getMeta();
|
|
257
|
-
// Defensive check: Validate meta is an object
|
|
258
|
-
if (!meta || typeof meta !== "object") {
|
|
259
|
-
this._lastViewMemo = null;
|
|
260
|
-
this._viewSignal.set(null);
|
|
261
|
-
return;
|
|
262
|
-
}
|
|
263
|
-
// Find meta.view in active state nodes
|
|
264
|
-
let viewMeta = null;
|
|
265
|
-
for (const [_stateId, stateMeta] of Object.entries(meta)) {
|
|
266
|
-
const maybeView = stateMeta && typeof stateMeta === "object"
|
|
267
|
-
? stateMeta.view
|
|
268
|
-
: undefined;
|
|
269
|
-
if (maybeView && typeof maybeView === "object") {
|
|
270
|
-
viewMeta = maybeView;
|
|
271
|
-
break;
|
|
272
|
-
}
|
|
273
|
-
}
|
|
274
|
-
if (!viewMeta) {
|
|
275
|
-
this._lastViewMemo = null;
|
|
276
|
-
this._viewSignal.set(null);
|
|
277
|
-
return;
|
|
278
|
-
}
|
|
279
|
-
// Validate component binding
|
|
565
|
+
validateAndCacheView(snapshot) {
|
|
280
566
|
try {
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
//
|
|
285
|
-
|
|
286
|
-
|
|
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;
|
|
287
576
|
}
|
|
288
|
-
|
|
289
|
-
this.
|
|
290
|
-
return;
|
|
291
|
-
}
|
|
292
|
-
// Merge meta.view with context for props
|
|
293
|
-
const props = mergeViewProps(viewMeta, snapshot.context);
|
|
294
|
-
// Use reference equality for snapshot.value (XState snapshots are immutable)
|
|
295
|
-
// Per user decision (CONS-11): ref equality on snapshot.value, createMemoSignature for props
|
|
296
|
-
const propsSignature = createMemoSignature(props);
|
|
297
|
-
if (this._lastViewMemo &&
|
|
298
|
-
this._lastViewMemo.snapshotValue === snapshot.value &&
|
|
299
|
-
this._lastViewMemo.component === viewMeta.component &&
|
|
300
|
-
this._lastViewMemo.propsSignature === propsSignature) {
|
|
301
|
-
this._viewSignal.set({
|
|
302
|
-
component: this._lastViewMemo.view.component,
|
|
303
|
-
props: this._lastViewMemo.view.props,
|
|
304
|
-
});
|
|
305
|
-
return;
|
|
577
|
+
this.lastEmittedView = view;
|
|
578
|
+
this.viewSignal.set(view);
|
|
306
579
|
}
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
if (this._options.onError) {
|
|
312
|
-
this._options.onError(this, new Error(`Invalid props for component "${viewMeta.component}": ${validation.error?.message || "Unknown error"}`));
|
|
313
|
-
}
|
|
314
|
-
console.error("View props validation failed:", validation.error);
|
|
315
|
-
// STRICT MODE (CONS-15): block rendering on validation failure
|
|
316
|
-
if (this._options.propValidation === "strict") {
|
|
317
|
-
this._lastViewMemo = null;
|
|
318
|
-
this._viewSignal.set(null);
|
|
319
|
-
return;
|
|
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);
|
|
320
584
|
}
|
|
321
|
-
//
|
|
322
|
-
// Documented trade-off: components may receive malformed props but app stays visible
|
|
585
|
+
// On error: keep the last valid view (don't clear)
|
|
323
586
|
}
|
|
324
|
-
// Cache validated view structure (validated once at state entry)
|
|
325
|
-
const newView = {
|
|
326
|
-
component: viewMeta.component,
|
|
327
|
-
props: validation.success ? validation.data : props,
|
|
328
|
-
};
|
|
329
|
-
this._lastViewMemo = {
|
|
330
|
-
snapshotValue: snapshot.value,
|
|
331
|
-
component: viewMeta.component,
|
|
332
|
-
propsSignature,
|
|
333
|
-
view: newView,
|
|
334
|
-
};
|
|
335
|
-
this._viewSignal.set(newView);
|
|
336
587
|
}
|
|
337
588
|
/**
|
|
338
589
|
* Convenience dispose method for cleanup
|