@xmachines/play-xstate 1.0.0 → 1.1.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/README.md +32 -14
- package/dist/errors.d.ts +46 -32
- package/dist/errors.d.ts.map +1 -1
- package/dist/errors.js +69 -32
- package/dist/errors.js.map +1 -1
- package/dist/guards/compose.d.ts +8 -0
- package/dist/guards/compose.d.ts.map +1 -1
- package/dist/guards/compose.js +7 -2
- package/dist/guards/compose.js.map +1 -1
- package/dist/guards/helpers.d.ts +6 -2
- package/dist/guards/helpers.d.ts.map +1 -1
- package/dist/guards/helpers.js +6 -2
- package/dist/guards/helpers.js.map +1 -1
- package/dist/guards/index.d.ts +7 -0
- package/dist/guards/index.d.ts.map +1 -1
- package/dist/guards/index.js +7 -0
- package/dist/guards/index.js.map +1 -1
- package/dist/guards/types.d.ts +4 -5
- package/dist/guards/types.d.ts.map +1 -1
- package/dist/player-actor.d.ts +105 -39
- package/dist/player-actor.d.ts.map +1 -1
- package/dist/player-actor.js +312 -318
- package/dist/player-actor.js.map +1 -1
- package/dist/routing/build-url.d.ts +2 -7
- package/dist/routing/build-url.d.ts.map +1 -1
- package/dist/routing/build-url.js +21 -25
- package/dist/routing/build-url.js.map +1 -1
- package/dist/routing/derive-current-route.d.ts +32 -0
- package/dist/routing/derive-current-route.d.ts.map +1 -1
- package/dist/routing/derive-current-route.js +20 -19
- package/dist/routing/derive-current-route.js.map +1 -1
- package/dist/routing/derive-initial-route.d.ts +0 -3
- package/dist/routing/derive-initial-route.d.ts.map +1 -1
- package/dist/routing/derive-initial-route.js +0 -3
- package/dist/routing/derive-initial-route.js.map +1 -1
- package/dist/routing/index.d.ts +1 -1
- package/dist/routing/index.d.ts.map +1 -1
- package/dist/routing/index.js +1 -1
- package/dist/routing/index.js.map +1 -1
- package/dist/types.d.ts +86 -13
- package/dist/types.d.ts.map +1 -1
- package/dist/view/derive-current-view.d.ts +54 -0
- package/dist/view/derive-current-view.d.ts.map +1 -0
- package/dist/view/derive-current-view.js +167 -0
- package/dist/view/derive-current-view.js.map +1 -0
- package/package.json +11 -14
- package/dist/define-player.typecheck.d.ts +0 -2
- package/dist/define-player.typecheck.d.ts.map +0 -1
- package/dist/define-player.typecheck.js +0 -48
- package/dist/define-player.typecheck.js.map +0 -1
- package/dist/player-actor.typecheck.d.ts +0 -2
- package/dist/player-actor.typecheck.d.ts.map +0 -1
- package/dist/player-actor.typecheck.js +0 -27
- package/dist/player-actor.typecheck.js.map +0 -1
package/dist/player-actor.js
CHANGED
|
@@ -1,125 +1,77 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { Actor, } from "xstate";
|
|
2
2
|
import { AbstractActor } from "@xmachines/play-actor";
|
|
3
3
|
import { Signal } from "@xmachines/play-signals";
|
|
4
|
-
import { InvalidEventError, InvalidMachineError } from "./errors.js";
|
|
4
|
+
import { ActorThrewNonErrorError, InvalidEventError, InvalidMachineError } from "./errors.js";
|
|
5
5
|
import { deriveCurrentRoute, deriveInitialRoute } from "./routing/index.js";
|
|
6
|
-
|
|
7
|
-
if (!snapshot || typeof snapshot !== "object") {
|
|
8
|
-
return false;
|
|
9
|
-
}
|
|
10
|
-
if (!("status" in snapshot)) {
|
|
11
|
-
return false;
|
|
12
|
-
}
|
|
13
|
-
return typeof snapshot.status === "string";
|
|
14
|
-
};
|
|
6
|
+
import { deriveCurrentView } from "./view/derive-current-view.js";
|
|
15
7
|
/**
|
|
16
|
-
*
|
|
17
|
-
*
|
|
18
|
-
*
|
|
19
|
-
*
|
|
8
|
+
* An `Error` by identity or by brand: `instanceof` misses errors constructed
|
|
9
|
+
* in another realm (an iframe, `node:vm`). Prefer `Error.isError` where the
|
|
10
|
+
* runtime has it (Node >= 24, Baseline-2025 browsers) — it rejects
|
|
11
|
+
* `Symbol.toStringTag` spoofs — and fall back to the brand check elsewhere.
|
|
12
|
+
* Typed structurally: the repo's lib target predates the API.
|
|
20
13
|
*/
|
|
21
|
-
const
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
const
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
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;
|
|
14
|
+
const isRealError = (value) => {
|
|
15
|
+
if (value instanceof Error)
|
|
16
|
+
return true;
|
|
17
|
+
const isError = Error.isError;
|
|
18
|
+
if (isError)
|
|
19
|
+
return isError(value);
|
|
20
|
+
return Object.prototype.toString.call(value) === "[object Error]";
|
|
42
21
|
};
|
|
43
22
|
/**
|
|
44
|
-
*
|
|
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.
|
|
23
|
+
* Normalize an actor failure for `onError`.
|
|
61
24
|
*
|
|
62
|
-
*
|
|
63
|
-
*
|
|
64
|
-
*
|
|
65
|
-
*
|
|
66
|
-
* // contextValues: { username: "alice" } ← from contextProps: ["username"]
|
|
67
|
-
* // result: { section: "profile", username: "alice", title: "Dashboard" }
|
|
68
|
-
* ```
|
|
25
|
+
* An `Error` is handed over unchanged so the machine's own error keeps its
|
|
26
|
+
* identity (a consumer's `instanceof` check still works, and the no-`onError`
|
|
27
|
+
* path rethrows that same object). Anything else is ours to construct, so it
|
|
28
|
+
* becomes a coded `PlayError` carrying the thrown value as `cause`.
|
|
69
29
|
*/
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
merged[k] = v; // nosemgrep: gitlab.eslint.detect-object-injection
|
|
30
|
+
const toError = (value) => {
|
|
31
|
+
try {
|
|
32
|
+
if (isRealError(value)) {
|
|
33
|
+
return value;
|
|
34
|
+
}
|
|
76
35
|
}
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
merged[k] = v; // nosemgrep: gitlab.eslint.detect-object-injection
|
|
36
|
+
catch {
|
|
37
|
+
// Classification itself can throw for hostile values (a revoked Proxy
|
|
38
|
+
// traps both instanceof and brand inspection) — fall through and wrap.
|
|
81
39
|
}
|
|
82
|
-
return
|
|
83
|
-
}
|
|
40
|
+
return new ActorThrewNonErrorError(value);
|
|
41
|
+
};
|
|
42
|
+
/** Own-key shallow equality with `Object.is`, ignoring `except` on both sides. */
|
|
43
|
+
const shallowEqualExcept = (a, b, except) => {
|
|
44
|
+
const aEntries = Object.entries(a).filter(([key]) => key !== except);
|
|
45
|
+
const bKeyCount = Object.keys(b).filter((key) => key !== except).length;
|
|
46
|
+
if (aEntries.length !== bKeyCount)
|
|
47
|
+
return false;
|
|
48
|
+
const bRecord = b;
|
|
49
|
+
for (const [key, value] of aEntries) {
|
|
50
|
+
// nosemgrep: gitlab.eslint.detect-object-injection
|
|
51
|
+
if (!Object.hasOwn(b, key) || !Object.is(value, bRecord[key]))
|
|
52
|
+
return false;
|
|
53
|
+
}
|
|
54
|
+
return true;
|
|
55
|
+
};
|
|
84
56
|
/**
|
|
85
|
-
*
|
|
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.
|
|
57
|
+
* Bounded structural equality for derived view specs.
|
|
97
58
|
*
|
|
98
|
-
*
|
|
99
|
-
*
|
|
59
|
+
* Walks exactly the shape `deriveCurrentView` builds — spec fields, then
|
|
60
|
+
* `elements`, then each element's `props` — comparing every leaf with
|
|
61
|
+
* `Object.is`. Prop VALUES are never entered: a changed reference re-emits
|
|
62
|
+
* even when contents are equal, which keeps container props (Map/Set/class
|
|
63
|
+
* instances, invisible to structural comparison) and cyclic values (unbounded
|
|
64
|
+
* recursion for it) correct by construction.
|
|
100
65
|
*/
|
|
101
|
-
const
|
|
66
|
+
const viewSpecsEquivalent = (a, b) => {
|
|
102
67
|
if (a === b)
|
|
103
68
|
return true;
|
|
104
|
-
if (a
|
|
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)
|
|
69
|
+
if (!a || !b)
|
|
110
70
|
return false;
|
|
111
|
-
|
|
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)
|
|
71
|
+
if (!shallowEqualExcept(a, b, "elements"))
|
|
122
72
|
return false;
|
|
73
|
+
const aElements = a.elements ?? {};
|
|
74
|
+
const bElements = b.elements ?? {};
|
|
123
75
|
const elementKeys = Object.keys(aElements);
|
|
124
76
|
if (elementKeys.length !== Object.keys(bElements).length)
|
|
125
77
|
return false;
|
|
@@ -130,126 +82,29 @@ const areViewSpecsEquivalent = (a, b) => {
|
|
|
130
82
|
continue;
|
|
131
83
|
if (!aElement || !bElement)
|
|
132
84
|
return false;
|
|
133
|
-
|
|
134
|
-
if (fieldKeys.length !== Object.keys(bElement).length)
|
|
85
|
+
if (!shallowEqualExcept(aElement, bElement, "props"))
|
|
135
86
|
return false;
|
|
136
|
-
|
|
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)
|
|
87
|
+
if (!shallowEqualExcept(aElement.props ?? {}, bElement.props ?? {}))
|
|
146
88
|
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
89
|
}
|
|
152
90
|
return true;
|
|
153
91
|
};
|
|
154
92
|
/**
|
|
155
|
-
*
|
|
156
|
-
*
|
|
157
|
-
*
|
|
158
|
-
*
|
|
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
|
-
* ```
|
|
93
|
+
* A snapshot worth propagating to signals: an active snapshot or a "done"
|
|
94
|
+
* snapshot (top-level final state reached). Error and stopped snapshots are
|
|
95
|
+
* skipped so signals keep the last observable state instead of surfacing
|
|
96
|
+
* teardown/error artifacts.
|
|
195
97
|
*/
|
|
196
|
-
const
|
|
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;
|
|
245
|
-
};
|
|
98
|
+
const isObservableSnapshot = (snapshot) => snapshot.status === "active" || snapshot.status === "done";
|
|
246
99
|
/**
|
|
247
100
|
* Concrete XState actor implementing Play Architecture signal protocol
|
|
248
101
|
*
|
|
249
|
-
* Extends {@link @xmachines/play-actor!AbstractActor}
|
|
250
|
-
* while maintaining ecosystem compatibility (XState
|
|
251
|
-
*
|
|
252
|
-
*
|
|
102
|
+
* Extends {@link @xmachines/play-actor!AbstractActor} — and so XState's own `Actor` —
|
|
103
|
+
* to provide XState v5 integration while maintaining ecosystem compatibility (XState
|
|
104
|
+
* inspection, devtools). The machine is handed to the base constructor, so a
|
|
105
|
+
* `PlayerActor` **is** the XState actor rather than a wrapper around one: everything
|
|
106
|
+
* XState's `Actor` exposes operates on this instance's own state, and the class adds
|
|
107
|
+
* TC39 Signal-based reactive state for Infrastructure observation on top.
|
|
253
108
|
*
|
|
254
109
|
* **Capabilities:** Implements both {@link @xmachines/play-actor!Routable} and
|
|
255
110
|
* {@link @xmachines/play-actor!Viewable} interfaces, providing routing and view
|
|
@@ -272,7 +127,14 @@ const deriveCurrentView = (snapshot) => {
|
|
|
272
127
|
* initial: 'idle',
|
|
273
128
|
* states: {
|
|
274
129
|
* idle: {
|
|
275
|
-
* meta: {
|
|
130
|
+
* meta: {
|
|
131
|
+
* route: '/',
|
|
132
|
+
* // A view spec needs `root` and `elements` — other shapes derive null.
|
|
133
|
+
* view: {
|
|
134
|
+
* root: 'home',
|
|
135
|
+
* elements: { home: { type: 'HomePage', props: {}, children: [] } },
|
|
136
|
+
* },
|
|
137
|
+
* },
|
|
276
138
|
* }
|
|
277
139
|
* }
|
|
278
140
|
* });
|
|
@@ -282,8 +144,8 @@ const deriveCurrentView = (snapshot) => {
|
|
|
282
144
|
* actor.start();
|
|
283
145
|
*
|
|
284
146
|
* // Observe signals
|
|
285
|
-
* console.log(actor.currentRoute.get());
|
|
286
|
-
* console.log(actor.currentView.get());
|
|
147
|
+
* console.log(actor.currentRoute.get()); // '/'
|
|
148
|
+
* console.log(actor.currentView.get()?.root); // 'home'
|
|
287
149
|
* ```
|
|
288
150
|
*
|
|
289
151
|
* @example
|
|
@@ -319,18 +181,42 @@ const deriveCurrentView = (snapshot) => {
|
|
|
319
181
|
* cached and updated at state entry, not computed on every read.
|
|
320
182
|
*/
|
|
321
183
|
export class PlayerActor extends AbstractActor {
|
|
322
|
-
xstateActor;
|
|
323
184
|
playerOptions;
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
185
|
+
/**
|
|
186
|
+
* The caller's live options bag, or an empty one during construction.
|
|
187
|
+
*
|
|
188
|
+
* XState hands this actor to a context factory as `self` and to an
|
|
189
|
+
* `inspect` observer as `actorRef` from inside its own constructor, before
|
|
190
|
+
* any field here is assigned. Reading hooks through this accessor keeps
|
|
191
|
+
* that window from throwing — a throw would be caught by XState's
|
|
192
|
+
* initialization and parked as an error snapshot — while preserving the
|
|
193
|
+
* read-at-delivery-time behaviour the bag's own docs promise.
|
|
194
|
+
*
|
|
195
|
+
* The window-sensitive fields below are `declare`d for the same reason.
|
|
196
|
+
* This target compiles class fields to `Object.defineProperty`, which runs
|
|
197
|
+
* after `super()` returns: a plain declaration — initializer or not — would
|
|
198
|
+
* reset anything the window had written back to `undefined`. `declare`
|
|
199
|
+
* emits nothing, so those writes survive.
|
|
200
|
+
*/
|
|
201
|
+
get hooks() {
|
|
202
|
+
return this.playerOptions ?? {};
|
|
203
|
+
}
|
|
204
|
+
/**
|
|
205
|
+
* The last snapshot the view pipeline processed. XState notifies observers
|
|
206
|
+
* on EVERY processed event — an ignored event redelivers the identical
|
|
207
|
+
* snapshot — and deriveCurrentView is pure in the snapshot, so an identical
|
|
208
|
+
* reference cannot change the outcome. Seeded with undefined (never a
|
|
209
|
+
* snapshot): the construction-time snapshot is reference-identical to the
|
|
210
|
+
* one start() replays, and seeding with it would suppress the initial view.
|
|
211
|
+
*/
|
|
212
|
+
lastViewSnapshot = undefined;
|
|
327
213
|
// AbstractActor protocol requirements
|
|
328
214
|
state;
|
|
329
215
|
/**
|
|
330
216
|
* Returns whether the actor's current state can accept the given event.
|
|
331
217
|
*
|
|
332
218
|
* Typed to the machine's event union — passing an unknown event type is a
|
|
333
|
-
* compile error.
|
|
219
|
+
* compile error. Evaluated against the current snapshot signal.
|
|
334
220
|
*
|
|
335
221
|
* @example
|
|
336
222
|
* ```typescript
|
|
@@ -338,23 +224,28 @@ export class PlayerActor extends AbstractActor {
|
|
|
338
224
|
* ```
|
|
339
225
|
*/
|
|
340
226
|
can(event) {
|
|
341
|
-
|
|
227
|
+
// Reading the signal keeps can() reactive: a Signal.Computed over it
|
|
228
|
+
// recomputes on transitions. Two states have no answer to give: the
|
|
229
|
+
// construction window, where no snapshot is readable yet, and an actor
|
|
230
|
+
// whose initialization failed — XState parks an error snapshot there,
|
|
231
|
+
// which is a truthy object with no `can` on it.
|
|
232
|
+
const snapshot = this.state?.get();
|
|
233
|
+
return typeof snapshot?.can === "function" ? snapshot.can(event) : false;
|
|
342
234
|
}
|
|
343
235
|
/**
|
|
344
236
|
* A TC39 `Signal.Computed` that derives the current URL path from the active
|
|
345
237
|
* machine state's `meta.route` template and the actor's context.
|
|
346
238
|
*
|
|
347
239
|
* Returns `null` when the current state has no `meta.route`, or when the route
|
|
348
|
-
* template cannot be fully resolved
|
|
349
|
-
*
|
|
350
|
-
*
|
|
351
|
-
*
|
|
352
|
-
* route template has no matching value in the actor's context. Import the class
|
|
353
|
-
* from `@xmachines/play-xstate/errors`.
|
|
240
|
+
* template cannot be fully resolved — a required `:param` absent from context
|
|
241
|
+
* is caught internally (`MissingRouteParamError` never escapes `get()`): the
|
|
242
|
+
* condition is transient mid-transition and the signal recomputes on the next
|
|
243
|
+
* snapshot.
|
|
354
244
|
*
|
|
355
245
|
* @example
|
|
356
246
|
* ```typescript
|
|
357
|
-
* // Returns "/profile/alice" when context.userId === "alice"
|
|
247
|
+
* // Returns "/profile/alice" when context.params.userId === "alice",
|
|
248
|
+
* // and null while the param is still missing.
|
|
358
249
|
* const route = actor.currentRoute.get();
|
|
359
250
|
* ```
|
|
360
251
|
*/
|
|
@@ -391,6 +282,11 @@ export class PlayerActor extends AbstractActor {
|
|
|
391
282
|
*
|
|
392
283
|
* Returns `null` when the current state has no `meta.view` metadata.
|
|
393
284
|
*
|
|
285
|
+
* Two states declaring separate but structurally identical `meta.view`
|
|
286
|
+
* literals emit distinct references on a transition between them (a
|
|
287
|
+
* provider remount); hoist the shared literal into one `typedSpec` constant
|
|
288
|
+
* to deduplicate by identity.
|
|
289
|
+
*
|
|
394
290
|
* @example
|
|
395
291
|
* ```typescript
|
|
396
292
|
* const view = actor.currentView.get();
|
|
@@ -400,96 +296,155 @@ export class PlayerActor extends AbstractActor {
|
|
|
400
296
|
* }
|
|
401
297
|
* ```
|
|
402
298
|
*/
|
|
403
|
-
currentView;
|
|
299
|
+
currentView = new Signal.State(null);
|
|
404
300
|
constructor(machine, options, input, restoredSnapshot) {
|
|
405
|
-
// Defensive check:
|
|
301
|
+
// Defensive check before super(): a non-object machine fails deep inside
|
|
302
|
+
// XState's constructor with an opaque TypeError instead of a coded error.
|
|
406
303
|
if (!machine || typeof machine !== "object") {
|
|
407
304
|
throw new InvalidMachineError();
|
|
408
305
|
}
|
|
409
|
-
//
|
|
410
|
-
// XState
|
|
411
|
-
//
|
|
412
|
-
//
|
|
413
|
-
//
|
|
414
|
-
//
|
|
306
|
+
// THIS is the actor. The machine and its runtime options go straight to
|
|
307
|
+
// XState's Actor constructor — the same arguments `createActor` forwards
|
|
308
|
+
// — so there is no second instance to answer for the real one, and every
|
|
309
|
+
// Actor member we do not override operates on real state.
|
|
310
|
+
//
|
|
311
|
+
// XState 5.28.0: the options bag has a conditional type constraint on
|
|
312
|
+
// `input` that TypeScript cannot resolve against an unbound generic
|
|
313
|
+
// TMachine. The cast stays inside the XState type system, and `input` is
|
|
314
|
+
// typed on this constructor's own signature, so callers keep their
|
|
315
|
+
// compile-time validation.
|
|
415
316
|
// Track XState typing improvements: https://github.com/statelyai/xstate/issues
|
|
416
|
-
|
|
417
|
-
const xstateActor = createActor(machine, {
|
|
317
|
+
super(machine, {
|
|
418
318
|
input,
|
|
419
319
|
snapshot: restoredSnapshot,
|
|
320
|
+
inspect: options?.inspect,
|
|
420
321
|
});
|
|
421
|
-
//
|
|
422
|
-
|
|
423
|
-
//
|
|
424
|
-
//
|
|
425
|
-
// XState's pure `initialTransition` helper
|
|
426
|
-
//
|
|
427
|
-
//
|
|
428
|
-
this.initialRoute =
|
|
429
|
-
|
|
322
|
+
// Derive the machine's initial route for restore-vs-deeplink detection —
|
|
323
|
+
// always the machine's DEFAULT initial route, never the restored state's.
|
|
324
|
+
// Without a restored snapshot this actor's own pre-start snapshot IS that
|
|
325
|
+
// default initial state, so derive from it directly; only a restore needs
|
|
326
|
+
// XState's pure `initialTransition` helper, whose inert actor scope runs
|
|
327
|
+
// the machine's initial transition twice more (once with `input`
|
|
328
|
+
// undefined) — an XState quirk worth paying only when required.
|
|
329
|
+
this.initialRoute =
|
|
330
|
+
restoredSnapshot === undefined
|
|
331
|
+
? deriveCurrentRoute(this.getSnapshot())
|
|
332
|
+
: deriveInitialRoute(machine, input);
|
|
430
333
|
this.playerOptions = options || {};
|
|
431
334
|
// Initialize state signal. Updates are synchronous (no microtask batching):
|
|
432
335
|
// XState already coalesces multiple transitions within a single send() into
|
|
433
336
|
// one subscription callback, and synchronous updates ensure guard-triggered
|
|
434
337
|
// redirects are immediately visible to router bridges.
|
|
435
|
-
this.state = new Signal.State(
|
|
436
|
-
// Initialize view signal
|
|
437
|
-
this.viewSignal = new Signal.State(null);
|
|
338
|
+
this.state = new Signal.State(this.getSnapshot());
|
|
438
339
|
// Initialize currentRoute computed signal
|
|
439
340
|
this.currentRoute = new Signal.Computed(() => {
|
|
440
341
|
const snapshot = this.state.get();
|
|
441
342
|
return deriveCurrentRoute(snapshot);
|
|
442
343
|
});
|
|
443
|
-
//
|
|
444
|
-
|
|
445
|
-
//
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
this.
|
|
344
|
+
// Observe our own transitions. `super` rather than `this` so the
|
|
345
|
+
// next-only bookkeeping in the subscribe override stays about userland
|
|
346
|
+
// subscriptions only.
|
|
347
|
+
super.subscribe({
|
|
348
|
+
next: (snapshot) => {
|
|
349
|
+
// Only update on stable states: active snapshots and the
|
|
350
|
+
// "done" snapshot from a top-level final state. Error/stopped snapshots
|
|
351
|
+
// are skipped so signals never freeze on teardown artifacts.
|
|
352
|
+
if (isObservableSnapshot(snapshot)) {
|
|
353
|
+
// State updates are synchronous so router bridges see guard redirects immediately.
|
|
354
|
+
this.state.set(snapshot);
|
|
355
|
+
// Validate and cache the view after state/currentRoute are current, before hooks.
|
|
356
|
+
// Hook ordering is intentional:
|
|
357
|
+
// 1. state/currentRoute updated
|
|
358
|
+
// 2. currentView cached
|
|
359
|
+
// 3. onStateChange hook
|
|
360
|
+
// 4. send() then invokes onTransition
|
|
361
|
+
this.validateAndCacheView(snapshot);
|
|
362
|
+
// Call onStateChange hook
|
|
363
|
+
const onStateChange = this.hooks.onStateChange;
|
|
364
|
+
if (onStateChange) {
|
|
365
|
+
onStateChange(this, snapshot);
|
|
366
|
+
}
|
|
463
367
|
}
|
|
464
|
-
}
|
|
368
|
+
},
|
|
369
|
+
// Always registered, handler read at DELIVERY time: the options bag is
|
|
370
|
+
// shared by reference, so an onError attached after construction still
|
|
371
|
+
// receives actor errors, and one removed later stops swallowing them.
|
|
372
|
+
// Without a handler the listener rethrows, which XState's observer
|
|
373
|
+
// dispatch routes to its global unhandled rethrow — the same loud
|
|
374
|
+
// default as not registering an error listener at all.
|
|
375
|
+
error: (error) => {
|
|
376
|
+
const handler = this.hooks.onError;
|
|
377
|
+
if (handler) {
|
|
378
|
+
handler(this, toError(error));
|
|
379
|
+
return;
|
|
380
|
+
}
|
|
381
|
+
// A userland next-only subscription already makes XState report
|
|
382
|
+
// this delivery globally; rethrowing here too would double it.
|
|
383
|
+
if ((this.nextOnlySubscriptions ?? 0) > 0) {
|
|
384
|
+
return;
|
|
385
|
+
}
|
|
386
|
+
throw error;
|
|
387
|
+
},
|
|
465
388
|
});
|
|
389
|
+
// Everything above is reachable from XState's own constructor callbacks;
|
|
390
|
+
// past this point the instance is whole.
|
|
391
|
+
this.constructed = true;
|
|
466
392
|
}
|
|
467
393
|
/**
|
|
468
|
-
* Start the actor
|
|
394
|
+
* Start the actor.
|
|
469
395
|
*
|
|
470
|
-
*
|
|
396
|
+
* Fires `onStart` on each real start — every transition from not-running to
|
|
397
|
+
* running, including a start after a stop, which XState allows (its own
|
|
398
|
+
* `start()` bails only while the actor is already RUNNING). A repeated call
|
|
399
|
+
* while running does not re-fire it, so a defensive double mount does not
|
|
400
|
+
* re-run `onStart` side effects for one actual start.
|
|
471
401
|
*/
|
|
472
402
|
start() {
|
|
473
|
-
|
|
474
|
-
//
|
|
475
|
-
if (this.
|
|
476
|
-
this
|
|
403
|
+
// See stop(): a call reaching in through the construction window would
|
|
404
|
+
// run a half-built actor.
|
|
405
|
+
if (!this.constructed) {
|
|
406
|
+
return this;
|
|
407
|
+
}
|
|
408
|
+
super.start();
|
|
409
|
+
if (this.lifecycle !== "running") {
|
|
410
|
+
this.lifecycle = "running";
|
|
411
|
+
const onStart = this.hooks.onStart;
|
|
412
|
+
if (onStart) {
|
|
413
|
+
onStart(this);
|
|
414
|
+
}
|
|
477
415
|
}
|
|
478
416
|
return this;
|
|
479
417
|
}
|
|
480
418
|
/**
|
|
481
|
-
* Stop the actor and
|
|
419
|
+
* Stop the actor and clean up.
|
|
420
|
+
*
|
|
421
|
+
* Fires `onStop` only when the actor was actually running — mirroring
|
|
422
|
+
* XState, where stopping a never-started or already-stopped actor is a
|
|
423
|
+
* no-op with zero teardown — so paired cleanup never runs twice, nor
|
|
424
|
+
* against resources `onStart` never acquired. Stopping does not close the
|
|
425
|
+
* actor for good: a later `start()` is a fresh lifecycle and fires
|
|
426
|
+
* `onStart` again.
|
|
482
427
|
*/
|
|
483
428
|
stop() {
|
|
484
|
-
|
|
485
|
-
//
|
|
486
|
-
|
|
487
|
-
|
|
429
|
+
// A call reaching in through the construction window (a context factory
|
|
430
|
+
// stopping its own `self`) would mark the actor stopped before its
|
|
431
|
+
// internal subscription is registered — XState drops observers added to
|
|
432
|
+
// a stopped actor, so the signals would never move again. There is
|
|
433
|
+
// nothing to tear down mid-construction, so ignore it.
|
|
434
|
+
if (!this.constructed) {
|
|
435
|
+
return this;
|
|
436
|
+
}
|
|
437
|
+
super.stop();
|
|
438
|
+
const wasRunning = this.lifecycle === "running";
|
|
439
|
+
this.lifecycle = "stopped";
|
|
440
|
+
const onStop = this.hooks.onStop;
|
|
441
|
+
if (wasRunning && onStop) {
|
|
442
|
+
onStop(this);
|
|
488
443
|
}
|
|
489
444
|
return this;
|
|
490
445
|
}
|
|
491
446
|
/**
|
|
492
|
-
* Send an event to
|
|
447
|
+
* Send an event to this actor.
|
|
493
448
|
*
|
|
494
449
|
* The actor's state machine guards decide whether the event causes a transition.
|
|
495
450
|
* Pass any event from the machine's event union — domain events, routing events, etc.
|
|
@@ -513,82 +468,121 @@ export class PlayerActor extends AbstractActor {
|
|
|
513
468
|
if (!event || typeof event !== "object") {
|
|
514
469
|
throw new InvalidEventError(event);
|
|
515
470
|
}
|
|
516
|
-
//
|
|
517
|
-
|
|
471
|
+
// Inside the construction window there is no readable snapshot, and no
|
|
472
|
+
// hook can have been registered yet: deliver and return.
|
|
473
|
+
if (!this.constructed) {
|
|
474
|
+
Actor.prototype.send.call(this, event);
|
|
475
|
+
return;
|
|
476
|
+
}
|
|
477
|
+
// Captured unconditionally — getSnapshot() is a single property read, and
|
|
478
|
+
// the options bag is live: an onTransition installed while this event is
|
|
479
|
+
// being processed (e.g. from onStateChange) must still fire for it with
|
|
480
|
+
// the correct pre-send snapshot.
|
|
481
|
+
const prevSnapshot = this.getSnapshot();
|
|
518
482
|
// Send to XState actor
|
|
519
|
-
//
|
|
520
|
-
|
|
483
|
+
// `AbstractActor` re-declares send() as abstract purely to narrow the
|
|
484
|
+
// event type, and TypeScript forbids super calls to an abstract member,
|
|
485
|
+
// so reach XState's implementation directly. `this` IS the actor, so this
|
|
486
|
+
// is exactly the call `super.send(event)` would make: the relay that
|
|
487
|
+
// emits the @xstate.event inspection event and enqueues on our mailbox.
|
|
488
|
+
Actor.prototype.send.call(this, event);
|
|
521
489
|
// Call onTransition hook
|
|
522
|
-
|
|
523
|
-
|
|
524
|
-
this.
|
|
490
|
+
const onTransition = this.hooks.onTransition;
|
|
491
|
+
if (onTransition) {
|
|
492
|
+
const nextSnapshot = this.getSnapshot();
|
|
493
|
+
onTransition(this, prevSnapshot, nextSnapshot);
|
|
525
494
|
}
|
|
526
495
|
}
|
|
527
496
|
/**
|
|
528
497
|
* Get current snapshot
|
|
529
498
|
*/
|
|
530
499
|
getSnapshot() {
|
|
531
|
-
return
|
|
500
|
+
return super.getSnapshot();
|
|
532
501
|
}
|
|
533
502
|
subscribe(nextListenerOrObserver, errorListener, completeListener) {
|
|
534
503
|
// XState's subscribe() normalizes function-or-observer internally; the cast
|
|
535
504
|
// only reconciles the overload signatures.
|
|
536
|
-
|
|
505
|
+
const subscription = super.subscribe(nextListenerOrObserver, errorListener, completeListener);
|
|
506
|
+
// Observers without an error listener make XState rethrow actor errors
|
|
507
|
+
// globally on their behalf; count them so the internal listener's own
|
|
508
|
+
// loud-default rethrow stands down while one is active (see the
|
|
509
|
+
// constructor's error listener).
|
|
510
|
+
const hasErrorListener = typeof nextListenerOrObserver === "object" && nextListenerOrObserver !== null
|
|
511
|
+
? typeof nextListenerOrObserver.error === "function"
|
|
512
|
+
: typeof errorListener === "function";
|
|
513
|
+
if (hasErrorListener) {
|
|
514
|
+
return subscription;
|
|
515
|
+
}
|
|
516
|
+
this.nextOnlySubscriptions = (this.nextOnlySubscriptions ?? 0) + 1;
|
|
517
|
+
let counted = true;
|
|
518
|
+
return {
|
|
519
|
+
unsubscribe: () => {
|
|
520
|
+
if (counted) {
|
|
521
|
+
counted = false;
|
|
522
|
+
this.nextOnlySubscriptions = (this.nextOnlySubscriptions ?? 1) - 1;
|
|
523
|
+
}
|
|
524
|
+
subscription.unsubscribe();
|
|
525
|
+
},
|
|
526
|
+
};
|
|
537
527
|
}
|
|
538
528
|
/**
|
|
539
|
-
* Listen for events
|
|
529
|
+
* Listen for events this actor emits via the `emit` action.
|
|
540
530
|
*
|
|
541
531
|
* @param type - Emitted event type to listen for, or `"*"` for all.
|
|
542
532
|
* @param handler - Called with each matching emitted event.
|
|
543
533
|
* @returns Subscription with an `unsubscribe()` method.
|
|
544
534
|
*/
|
|
545
535
|
on(type, handler) {
|
|
546
|
-
return
|
|
536
|
+
return super.on(type, handler);
|
|
547
537
|
}
|
|
548
538
|
/**
|
|
549
|
-
* Get
|
|
539
|
+
* Get this actor's persisted snapshot.
|
|
550
540
|
*
|
|
551
541
|
* Suitable for serialization and later restoration via the factory's
|
|
552
542
|
* `restore.snapshot` option.
|
|
553
543
|
*/
|
|
554
|
-
getPersistedSnapshot() {
|
|
555
|
-
|
|
544
|
+
getPersistedSnapshot(options) {
|
|
545
|
+
const forward = super.getPersistedSnapshot;
|
|
546
|
+
return forward.call(this, options);
|
|
556
547
|
}
|
|
557
548
|
/**
|
|
558
|
-
*
|
|
559
|
-
*
|
|
560
|
-
* Per CONTEXT.md: "Prop validation: At state entry (when state becomes active)"
|
|
561
|
-
* Validates once per transition and stores result in signal.
|
|
549
|
+
* Derive and cache the view at state entry — once per transition, stored in
|
|
550
|
+
* the signal rather than recomputed per read.
|
|
562
551
|
*
|
|
563
552
|
* @param snapshot - Current XState snapshot
|
|
564
553
|
*/
|
|
565
554
|
validateAndCacheView(snapshot) {
|
|
555
|
+
if (snapshot === this.lastViewSnapshot) {
|
|
556
|
+
return;
|
|
557
|
+
}
|
|
558
|
+
this.lastViewSnapshot = snapshot;
|
|
566
559
|
try {
|
|
567
560
|
const view = deriveCurrentView(snapshot);
|
|
568
|
-
// Emit only when the rendered view actually changed
|
|
569
|
-
// returns a fresh object
|
|
570
|
-
//
|
|
571
|
-
//
|
|
572
|
-
//
|
|
573
|
-
//
|
|
574
|
-
|
|
561
|
+
// Emit only when the rendered view actually changed: deriveCurrentView
|
|
562
|
+
// returns a fresh object per call, so reference identity cannot tell,
|
|
563
|
+
// and re-emitting a fresh reference for a snapshot that does not alter
|
|
564
|
+
// the view (e.g. a context-only assign) would make downstream providers
|
|
565
|
+
// remount the UI, wiping in-view state. Deep equality is deliberately
|
|
566
|
+
// NOT used here — it cannot see inside Maps/Sets (suppressing genuine
|
|
567
|
+
// changes) and recurses forever on cyclic props. The last emitted spec
|
|
568
|
+
// IS the signal's current value; read it untracked so the gate never
|
|
569
|
+
// registers currentView as a dependency of a surrounding computation.
|
|
570
|
+
const lastEmittedView = Signal.subtle.untrack(() => this.currentView.get());
|
|
571
|
+
if (viewSpecsEquivalent(lastEmittedView, view)) {
|
|
575
572
|
return;
|
|
576
573
|
}
|
|
577
|
-
this.
|
|
578
|
-
this.viewSignal.set(view);
|
|
574
|
+
this.currentView.set(view);
|
|
579
575
|
}
|
|
580
576
|
catch (error) {
|
|
581
|
-
|
|
582
|
-
|
|
583
|
-
|
|
577
|
+
const onError = this.hooks.onError;
|
|
578
|
+
if (onError) {
|
|
579
|
+
onError(this, toError(error));
|
|
584
580
|
}
|
|
585
581
|
// On error: keep the last valid view (don't clear)
|
|
586
582
|
}
|
|
587
583
|
}
|
|
588
584
|
/**
|
|
589
|
-
* Convenience dispose method for cleanup
|
|
590
|
-
*
|
|
591
|
-
* Per CONTEXT.md: "Both .dispose() convenience method and manual machine.stop()"
|
|
585
|
+
* Convenience dispose method for cleanup — an alias for {@link stop}.
|
|
592
586
|
*/
|
|
593
587
|
dispose() {
|
|
594
588
|
this.stop();
|