@xmachines/play-xstate 2.0.0-alpha.1 → 2.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 +114 -114
- package/dist/define-player.d.ts +16 -16
- package/dist/define-player.js +16 -16
- package/dist/errors.d.ts +84 -101
- package/dist/errors.d.ts.map +1 -1
- package/dist/errors.js +108 -108
- package/dist/errors.js.map +1 -1
- package/dist/guards/compose.d.ts +70 -77
- package/dist/guards/compose.d.ts.map +1 -1
- package/dist/guards/compose.js +90 -113
- package/dist/guards/compose.js.map +1 -1
- package/dist/guards/helpers.d.ts +22 -18
- package/dist/guards/helpers.d.ts.map +1 -1
- package/dist/guards/helpers.js +23 -19
- package/dist/guards/helpers.js.map +1 -1
- package/dist/guards/index.d.ts +10 -3
- package/dist/guards/index.d.ts.map +1 -1
- package/dist/guards/index.js +10 -3
- package/dist/guards/index.js.map +1 -1
- package/dist/guards/types.d.ts +9 -9
- package/dist/guards/types.d.ts.map +1 -1
- package/dist/index.d.ts +8 -8
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +9 -10
- package/dist/index.js.map +1 -1
- package/dist/player-actor.d.ts +197 -113
- package/dist/player-actor.d.ts.map +1 -1
- package/dist/player-actor.js +413 -401
- package/dist/player-actor.js.map +1 -1
- package/dist/routing/build-url.d.ts +19 -21
- package/dist/routing/build-url.d.ts.map +1 -1
- package/dist/routing/build-url.js +70 -71
- package/dist/routing/build-url.js.map +1 -1
- package/dist/routing/derive-current-route.d.ts +51 -13
- package/dist/routing/derive-current-route.d.ts.map +1 -1
- package/dist/routing/derive-current-route.js +69 -60
- package/dist/routing/derive-current-route.js.map +1 -1
- package/dist/routing/derive-initial-route.d.ts +23 -23
- package/dist/routing/derive-initial-route.js +27 -27
- package/dist/routing/derive-initial-route.js.map +1 -1
- package/dist/routing/derive-route.d.ts +38 -37
- package/dist/routing/derive-route.d.ts.map +1 -1
- package/dist/routing/derive-route.js +45 -42
- package/dist/routing/derive-route.js.map +1 -1
- package/dist/routing/format-play-route-transitions.d.ts +34 -71
- package/dist/routing/format-play-route-transitions.d.ts.map +1 -1
- package/dist/routing/format-play-route-transitions.js +74 -130
- package/dist/routing/format-play-route-transitions.js.map +1 -1
- package/dist/routing/index.d.ts +4 -8
- package/dist/routing/index.d.ts.map +1 -1
- package/dist/routing/index.js +4 -6
- package/dist/routing/index.js.map +1 -1
- package/dist/routing/types.d.ts +12 -11
- package/dist/routing/types.d.ts.map +1 -1
- package/dist/types.d.ts +97 -20
- package/dist/types.d.ts.map +1 -1
- package/dist/view/derive-current-view.d.ts +51 -0
- package/dist/view/derive-current-view.d.ts.map +1 -0
- package/dist/view/derive-current-view.js +119 -0
- package/dist/view/derive-current-view.js.map +1 -0
- package/package.json +22 -21
- 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/guards/compose.typecheck.d.ts +0 -2
- package/dist/guards/compose.typecheck.d.ts.map +0 -1
- package/dist/guards/compose.typecheck.js +0 -22
- package/dist/guards/compose.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 -30
- package/dist/player-actor.typecheck.js.map +0 -1
- package/dist/routing/create-routed-machine.d.ts +0 -71
- package/dist/routing/create-routed-machine.d.ts.map +0 -1
- package/dist/routing/create-routed-machine.js +0 -71
- package/dist/routing/create-routed-machine.js.map +0 -1
- package/dist/routing/play-route-event.typecheck.d.ts +0 -2
- package/dist/routing/play-route-event.typecheck.d.ts.map +0 -1
- package/dist/routing/play-route-event.typecheck.js +0 -43
- package/dist/routing/play-route-event.typecheck.js.map +0 -1
- package/dist/routing/schemas.d.ts +0 -99
- package/dist/routing/schemas.d.ts.map +0 -1
- package/dist/routing/schemas.js +0 -30
- package/dist/routing/schemas.js.map +0 -1
- package/dist/schemas.d.ts +0 -28
- package/dist/schemas.d.ts.map +0 -1
- package/dist/schemas.js +0 -29
- package/dist/schemas.js.map +0 -1
package/dist/player-actor.js
CHANGED
|
@@ -1,125 +1,73 @@
|
|
|
1
|
-
import {
|
|
2
|
-
import { AbstractActor } from "@xmachines/play-actor";
|
|
1
|
+
import { Actor, } from "xstate";
|
|
2
|
+
import { AbstractActor, reuseComposedState, shallowEqualExcept, } 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
|
+
* Tells you if a value is an `Error`, by its identity or by its brand: `instanceof`
|
|
9
|
+
* misses an error from another realm, such as an iframe or `node:vm`. The function
|
|
10
|
+
* prefers `Error.isError` where the runtime has it (Node >= 24, and a
|
|
11
|
+
* Baseline-2025 browser), because that function refuses a false
|
|
12
|
+
* `Symbol.toStringTag`. In every other runtime the function tests the brand. The
|
|
13
|
+
* type is structural, because the lib target of this repository is older than the
|
|
14
|
+
* API.
|
|
20
15
|
*/
|
|
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;
|
|
16
|
+
const isRealError = (value) => {
|
|
17
|
+
if (value instanceof Error)
|
|
18
|
+
return true;
|
|
19
|
+
const isError = Error.isError;
|
|
20
|
+
if (isError)
|
|
21
|
+
return isError(value);
|
|
22
|
+
return Object.prototype.toString.call(value) === "[object Error]";
|
|
42
23
|
};
|
|
43
24
|
/**
|
|
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.
|
|
25
|
+
* Normalizes a failure of the actor for `onError`.
|
|
61
26
|
*
|
|
62
|
-
*
|
|
63
|
-
*
|
|
64
|
-
*
|
|
65
|
-
*
|
|
66
|
-
*
|
|
67
|
-
* // result: { section: "profile", username: "alice", title: "Dashboard" }
|
|
68
|
-
* ```
|
|
27
|
+
* The function gives an `Error` to the handler without a change. The error of the
|
|
28
|
+
* machine therefore keeps its identity: an `instanceof` test of a consumer still
|
|
29
|
+
* works, and the path without an `onError` throws that same object again. Every
|
|
30
|
+
* other value is ours to build, and it becomes a `PlayError` with a code. That
|
|
31
|
+
* error carries the value from the throw as its `cause`.
|
|
69
32
|
*/
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
merged[k] = v; // nosemgrep: gitlab.eslint.detect-object-injection
|
|
33
|
+
const toError = (value) => {
|
|
34
|
+
try {
|
|
35
|
+
if (isRealError(value)) {
|
|
36
|
+
return value;
|
|
37
|
+
}
|
|
76
38
|
}
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
39
|
+
catch {
|
|
40
|
+
// The classification itself can throw for a hostile value, because a revoked
|
|
41
|
+
// Proxy traps instanceof and also the inspection of the brand. Continue, and wrap
|
|
42
|
+
// the value.
|
|
81
43
|
}
|
|
82
|
-
return
|
|
83
|
-
}
|
|
44
|
+
return new ActorThrewNonErrorError(value);
|
|
45
|
+
};
|
|
84
46
|
/**
|
|
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:
|
|
47
|
+
* The structural equality of two derived view specs, with a limit on its depth.
|
|
90
48
|
*
|
|
91
|
-
*
|
|
92
|
-
*
|
|
93
|
-
*
|
|
94
|
-
*
|
|
95
|
-
*
|
|
96
|
-
*
|
|
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).
|
|
49
|
+
* The function walks exactly the shape that `deriveCurrentView` builds: the spec
|
|
50
|
+
* fields, then `elements`, then the `props` object of each element. It compares
|
|
51
|
+
* each leaf with `Object.is`. It never enters the VALUE of a prop: a new reference
|
|
52
|
+
* therefore emits the view again, also when the contents are equal. This design
|
|
53
|
+
* keeps two things correct: a prop of a container (a Map, a Set, or an instance of a
|
|
54
|
+
* class, which a structural comparison cannot see), and a cyclic value, which gives
|
|
55
|
+
* a structural comparison a recursion without an end.
|
|
100
56
|
*/
|
|
101
|
-
const
|
|
57
|
+
const viewSpecsEquivalent = (a, b) => {
|
|
102
58
|
if (a === b)
|
|
103
59
|
return true;
|
|
104
|
-
if (a
|
|
60
|
+
if (!a || !b)
|
|
105
61
|
return false;
|
|
106
|
-
|
|
107
|
-
const bRecord = b;
|
|
108
|
-
const topKeys = Object.keys(aRecord);
|
|
109
|
-
if (topKeys.length !== Object.keys(bRecord).length)
|
|
62
|
+
if (!shallowEqualExcept(a, b, "elements"))
|
|
110
63
|
return false;
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
}
|
|
117
|
-
const aElements = (aRecord.elements ?? null);
|
|
118
|
-
const bElements = (bRecord.elements ?? null);
|
|
64
|
+
const aElements = a.elements ?? {};
|
|
65
|
+
const bElements = b.elements ?? {};
|
|
66
|
+
// A derived spec spreads the same static meta.view. Therefore the elements
|
|
67
|
+
// usually have the same reference, and the walk over each element is then not
|
|
68
|
+
// necessary.
|
|
119
69
|
if (aElements === bElements)
|
|
120
70
|
return true;
|
|
121
|
-
if (aElements === null || bElements === null)
|
|
122
|
-
return false;
|
|
123
71
|
const elementKeys = Object.keys(aElements);
|
|
124
72
|
if (elementKeys.length !== Object.keys(bElements).length)
|
|
125
73
|
return false;
|
|
@@ -130,140 +78,47 @@ const areViewSpecsEquivalent = (a, b) => {
|
|
|
130
78
|
continue;
|
|
131
79
|
if (!aElement || !bElement)
|
|
132
80
|
return false;
|
|
133
|
-
|
|
134
|
-
if (fieldKeys.length !== Object.keys(bElement).length)
|
|
81
|
+
if (!shallowEqualExcept(aElement, bElement, "props"))
|
|
135
82
|
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)
|
|
83
|
+
if (!shallowEqualExcept(aElement.props ?? {}, bElement.props ?? {}))
|
|
146
84
|
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
85
|
}
|
|
152
86
|
return true;
|
|
153
87
|
};
|
|
154
88
|
/**
|
|
155
|
-
*
|
|
156
|
-
*
|
|
157
|
-
*
|
|
158
|
-
*
|
|
159
|
-
*
|
|
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
|
-
* ```
|
|
89
|
+
* Tells you if a snapshot is worth a propagation to the signals: an active
|
|
90
|
+
* snapshot, or a "done" snapshot, which means that the machine reached a final state
|
|
91
|
+
* at the top level. The code skips an error snapshot and a stopped snapshot.
|
|
92
|
+
* Therefore the signals keep the last observable state, and they show no artifact of
|
|
93
|
+
* a teardown or of an error.
|
|
195
94
|
*/
|
|
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
|
-
};
|
|
95
|
+
const isObservableSnapshot = (snapshot) => snapshot.status === "active" || snapshot.status === "done";
|
|
246
96
|
/**
|
|
247
|
-
*
|
|
97
|
+
* The concrete XState actor. It implements the signal protocol of the Play Architecture
|
|
248
98
|
*
|
|
249
|
-
*
|
|
250
|
-
*
|
|
251
|
-
*
|
|
252
|
-
*
|
|
99
|
+
* The class extends {@link @xmachines/play-actor!AbstractActor}, and therefore the
|
|
100
|
+
* `Actor` class of XState. It gives you the XState v5 integration, and it keeps the
|
|
101
|
+
* compatibility with the ecosystem, such as the XState inspection and the devtools.
|
|
102
|
+
* The constructor of the base class receives the machine. Therefore a `PlayerActor`
|
|
103
|
+
* **is** the XState actor, and it is no wrapper around one: every member of the
|
|
104
|
+
* XState `Actor` class works on the state of this instance, and this class adds the
|
|
105
|
+
* reactive state on the TC39 Signals for the observation by the infrastructure.
|
|
253
106
|
*
|
|
254
|
-
* **Capabilities:**
|
|
255
|
-
* {@link @xmachines/play-actor!
|
|
256
|
-
*
|
|
107
|
+
* **Capabilities:** the class implements both the
|
|
108
|
+
* {@link @xmachines/play-actor!Routable} interface and the
|
|
109
|
+
* {@link @xmachines/play-actor!Viewable} interface. It therefore supports the
|
|
110
|
+
* routing and the view rendering.
|
|
257
111
|
*
|
|
258
|
-
* **Architectural
|
|
259
|
-
* XState machine
|
|
260
|
-
* the actor
|
|
261
|
-
*
|
|
112
|
+
* **Architectural context:** the class implements **Actor Authority (INV-01)**,
|
|
113
|
+
* because the guards of the XState machine control every decision of the
|
|
114
|
+
* navigation. The infrastructure observes the signals of the actor (`state`,
|
|
115
|
+
* `currentRoute`, and `currentView`), but it changes no state directly: every
|
|
116
|
+
* change goes through the event handlers of the state machine.
|
|
262
117
|
*
|
|
263
|
-
* @typeParam TMachine - XState
|
|
118
|
+
* @typeParam TMachine - The type of the XState v5 state machine
|
|
264
119
|
*
|
|
265
120
|
* @example
|
|
266
|
-
*
|
|
121
|
+
* The creation of an actor, and its lifecycle
|
|
267
122
|
* ```typescript
|
|
268
123
|
* import { setup } from "xstate";
|
|
269
124
|
* import { definePlayer } from "@xmachines/play-xstate";
|
|
@@ -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`. Every other shape derives null.
|
|
133
|
+
* view: {
|
|
134
|
+
* root: 'home',
|
|
135
|
+
* elements: { home: { type: 'HomePage', props: {}, children: [] } },
|
|
136
|
+
* },
|
|
137
|
+
* },
|
|
276
138
|
* }
|
|
277
139
|
* }
|
|
278
140
|
* });
|
|
@@ -281,13 +143,13 @@ const deriveCurrentView = (snapshot) => {
|
|
|
281
143
|
* const actor = createPlayer();
|
|
282
144
|
* actor.start();
|
|
283
145
|
*
|
|
284
|
-
* // Observe signals
|
|
285
|
-
* console.log(actor.currentRoute.get());
|
|
286
|
-
* console.log(actor.currentView.get());
|
|
146
|
+
* // Observe the signals
|
|
147
|
+
* console.log(actor.currentRoute.get()); // '/'
|
|
148
|
+
* console.log(actor.currentView.get()?.root); // 'home'
|
|
287
149
|
* ```
|
|
288
150
|
*
|
|
289
151
|
* @example
|
|
290
|
-
*
|
|
152
|
+
* The signal lifecycle with a watcher
|
|
291
153
|
* ```typescript
|
|
292
154
|
* import { Signal } from "@xmachines/play-signals";
|
|
293
155
|
*
|
|
@@ -300,39 +162,63 @@ const deriveCurrentView = (snapshot) => {
|
|
|
300
162
|
*
|
|
301
163
|
* watcher.watch(actor.state);
|
|
302
164
|
* actor.send({ type: 'play.route', to: '#about' });
|
|
303
|
-
* //
|
|
165
|
+
* // The watcher schedules its own notification in a microtask
|
|
304
166
|
* ```
|
|
305
167
|
*
|
|
306
168
|
* @see [Play RFC](../../docs/rfc/play.md)
|
|
307
|
-
* @see {@link definePlayer} for
|
|
308
|
-
* @see {@link @xmachines/play-actor!AbstractActor} for signal protocol
|
|
309
|
-
* @see {@link @xmachines/play-actor!Routable} for routing capability
|
|
310
|
-
* @see {@link @xmachines/play-actor!Viewable} for view rendering capability
|
|
169
|
+
* @see {@link definePlayer} for the creation through a factory
|
|
170
|
+
* @see {@link @xmachines/play-actor!AbstractActor} for the signal protocol
|
|
171
|
+
* @see {@link @xmachines/play-actor!Routable} for the routing capability
|
|
172
|
+
* @see {@link @xmachines/play-actor!Viewable} for the view rendering capability
|
|
311
173
|
*
|
|
312
174
|
* @remarks
|
|
313
|
-
* **
|
|
314
|
-
*
|
|
315
|
-
* `meta.route
|
|
175
|
+
* **The routing:** this actor supports the `route: {}` config pattern of XState and
|
|
176
|
+
* also a `play.route` event with parameters. The `deriveRoute()` function reads
|
|
177
|
+
* `meta.route`, which is the Stately pattern, for a URL template, and it substitutes
|
|
178
|
+
* each parameter.
|
|
316
179
|
*
|
|
317
|
-
* **
|
|
318
|
-
* `Signal.
|
|
319
|
-
*
|
|
180
|
+
* **The pattern of the view signal:** the `currentView` signal is a direct
|
|
181
|
+
* `Signal.State`, and not a `Signal.Computed`. The propagation to a watcher in
|
|
182
|
+
* PlayRenderer is therefore correct. The class derives each view at the entry of a
|
|
183
|
+
* state and keeps it, and it computes no view on a read.
|
|
320
184
|
*/
|
|
321
185
|
export class PlayerActor extends AbstractActor {
|
|
322
|
-
xstateActor;
|
|
323
186
|
playerOptions;
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
187
|
+
/**
|
|
188
|
+
* The live options object of the caller, or an empty object during the construction.
|
|
189
|
+
*
|
|
190
|
+
* XState gives this actor to a context factory as `self`, and to an `inspect`
|
|
191
|
+
* observer as `actorRef`, from inside its own constructor, before the code assigns
|
|
192
|
+
* any field here. A read of a hook through this accessor therefore does not throw
|
|
193
|
+
* in that window. A throw goes to the initialization of XState, which parks it as
|
|
194
|
+
* an error snapshot. The accessor also keeps the behavior that the documentation of
|
|
195
|
+
* the object promises: a read at the moment of the delivery.
|
|
196
|
+
*
|
|
197
|
+
* The fields below that this window touches have a `declare` modifier for the same
|
|
198
|
+
* reason. This target compiles a class field into `Object.defineProperty`, which
|
|
199
|
+
* runs after `super()` returns. A plain declaration, with an initializer or without
|
|
200
|
+
* one, therefore resets each value of the window to `undefined`. A `declare`
|
|
201
|
+
* modifier emits nothing, and those values survive.
|
|
202
|
+
*/
|
|
203
|
+
get hooks() {
|
|
204
|
+
return this.playerOptions ?? {};
|
|
205
|
+
}
|
|
206
|
+
/**
|
|
207
|
+
* The last snapshot of the view pipeline. XState notifies each observer on EVERY
|
|
208
|
+
* event that it processes, and an event that it ignores delivers the identical
|
|
209
|
+
* snapshot again. deriveCurrentView is pure in the snapshot. Therefore an identical
|
|
210
|
+
* reference can change no result. The first value is undefined, and never a
|
|
211
|
+
* snapshot: the snapshot of the construction has the same reference as the snapshot
|
|
212
|
+
* that start() replays, and that value therefore stops the first view.
|
|
213
|
+
*/
|
|
214
|
+
lastViewSnapshot = undefined;
|
|
215
|
+
// The requirements of the AbstractActor protocol
|
|
330
216
|
state;
|
|
331
217
|
/**
|
|
332
|
-
*
|
|
218
|
+
* Tells you if the current state of the actor accepts the given event.
|
|
333
219
|
*
|
|
334
|
-
*
|
|
335
|
-
* compile error.
|
|
220
|
+
* The type is the event union of the machine. An unknown event type is therefore a
|
|
221
|
+
* compile error. The method evaluates the event against the snapshot signal.
|
|
336
222
|
*
|
|
337
223
|
* @example
|
|
338
224
|
* ```typescript
|
|
@@ -340,261 +226,387 @@ export class PlayerActor extends AbstractActor {
|
|
|
340
226
|
* ```
|
|
341
227
|
*/
|
|
342
228
|
can(event) {
|
|
343
|
-
//
|
|
344
|
-
//
|
|
345
|
-
//
|
|
346
|
-
|
|
229
|
+
// A read of the signal keeps can() reactive: a Signal.Computed over the signal
|
|
230
|
+
// computes its value again on each transition. Two states have no answer: the
|
|
231
|
+
// construction window, where the code can read no snapshot, and an actor with a
|
|
232
|
+
// failed initialization, where XState parks an error snapshot. That snapshot is
|
|
233
|
+
// a truthy object, and it has no `can` method.
|
|
234
|
+
const snapshot = this.state?.get();
|
|
235
|
+
return typeof snapshot?.can === "function" ? snapshot.can(event) : false;
|
|
347
236
|
}
|
|
348
237
|
/**
|
|
349
|
-
* A TC39 `Signal.Computed
|
|
350
|
-
* machine state
|
|
238
|
+
* A TC39 `Signal.Computed`. It derives the current URL path from the `meta.route`
|
|
239
|
+
* template of the active machine state and from the context of the actor.
|
|
351
240
|
*
|
|
352
|
-
*
|
|
353
|
-
*
|
|
354
|
-
* context
|
|
355
|
-
*
|
|
356
|
-
*
|
|
357
|
-
*
|
|
358
|
-
* from `@xmachines/play-xstate/errors`.
|
|
241
|
+
* It returns `null` when the current state has no `meta.route` field, and also when
|
|
242
|
+
* it cannot resolve the complete route template. A necessary `:param` that the
|
|
243
|
+
* context does not hold is caught inside the signal, and a
|
|
244
|
+
* `MissingRouteParamError` therefore never leaves `get()`: that condition is
|
|
245
|
+
* temporary during a transition, and the signal computes the value again on the next
|
|
246
|
+
* snapshot.
|
|
359
247
|
*
|
|
360
248
|
* @example
|
|
361
249
|
* ```typescript
|
|
362
|
-
* //
|
|
250
|
+
* // It returns "/profile/alice" when context.params.userId === "alice",
|
|
251
|
+
* // and null while the param is still absent.
|
|
363
252
|
* const route = actor.currentRoute.get();
|
|
364
253
|
* ```
|
|
365
254
|
*/
|
|
366
255
|
currentRoute;
|
|
367
256
|
/**
|
|
368
|
-
* The route
|
|
369
|
-
* never changes
|
|
257
|
+
* The route of the initial state of the machine. The constructor fixes it, and it
|
|
258
|
+
* never changes, also when the code restores the actor from a snapshot.
|
|
370
259
|
*
|
|
371
|
-
*
|
|
372
|
-
* (
|
|
373
|
-
* different
|
|
260
|
+
* A router bridge compares it with the browser URL, and it therefore separates a
|
|
261
|
+
* deep link (a URL that is not the initial one → the router wins) from a restore
|
|
262
|
+
* (the initial URL, and the actor at a different route from the restore → the actor
|
|
263
|
+
* wins).
|
|
374
264
|
*
|
|
375
|
-
*
|
|
376
|
-
*
|
|
377
|
-
* `meta.route` templates are fixed at
|
|
378
|
-
* substitution uses the
|
|
379
|
-
*
|
|
380
|
-
* value
|
|
265
|
+
* `deriveInitialRoute` derives the value statically from the machine definition,
|
|
266
|
+
* with the pure `initialTransition` helper of XState: the chain of the initial states
|
|
267
|
+
* and their `meta.route` templates are fixed at the moment of the machine
|
|
268
|
+
* definition, and the substitution of a `:param` uses the real initial context of
|
|
269
|
+
* the machine for the `input` of this actor. The code makes no second actor, and a
|
|
270
|
+
* snapshot of a restore changes the value never: it is always the **default**
|
|
271
|
+
* initial route of the machine.
|
|
381
272
|
*/
|
|
382
273
|
initialRoute;
|
|
383
274
|
/**
|
|
384
|
-
*
|
|
385
|
-
* `meta.view` metadata.
|
|
275
|
+
* The reactive signal of the current view spec. The signal derives the spec from
|
|
276
|
+
* the `meta.view` metadata of the active state.
|
|
277
|
+
*
|
|
278
|
+
* It emits a **new object reference** on each real change of the view on the
|
|
279
|
+
* screen: the view of a different state, or a change of a param or of the context
|
|
280
|
+
* that changes the resolved spec. A re-entry with `reenter: true` and new params
|
|
281
|
+
* also changes the spec. A snapshot that changes no view on the screen, such as an
|
|
282
|
+
* assign of the context alone, keeps the previous reference. A provider below the
|
|
283
|
+
* signal therefore mounts the UI again not on every event.
|
|
386
284
|
*
|
|
387
|
-
*
|
|
388
|
-
*
|
|
389
|
-
*
|
|
390
|
-
*
|
|
391
|
-
*
|
|
285
|
+
* The `PlaySpec` of the emission carries the context of the machine in its composed
|
|
286
|
+
* `state` field, under the read-only `/context` subtree. A spec therefore reads the
|
|
287
|
+
* context, and also each URL param, through the ordinary state grammar
|
|
288
|
+
* (`{ $state: "/context/params/section" }`). The context-projection module of
|
|
289
|
+
* `@xmachines/play-actor` holds the complete contract.
|
|
392
290
|
*
|
|
393
|
-
* The
|
|
394
|
-
* before emission — URL path parameters (e.g. `:section?`) flow into component props
|
|
395
|
-
* automatically. See `mergeRouteParamsIntoProps` for the merge priority rules.
|
|
291
|
+
* The signal returns `null` when the current state has no `meta.view` metadata.
|
|
396
292
|
*
|
|
397
|
-
*
|
|
293
|
+
* Two states can declare two separate `meta.view` literals with an identical
|
|
294
|
+
* structure. A transition between those two states then emits two different
|
|
295
|
+
* references, and a provider mounts the UI again. Move the shared literal into one
|
|
296
|
+
* `typedSpec` constant, and the identity then removes the duplicate.
|
|
398
297
|
*
|
|
399
298
|
* @example
|
|
400
299
|
* ```typescript
|
|
401
300
|
* const view = actor.currentView.get();
|
|
402
301
|
* if (view) {
|
|
403
|
-
* console.log(view.root); //
|
|
404
|
-
* console.log(view.elements); // @xmachines/json-render-core
|
|
302
|
+
* console.log(view.root); // for example "root"
|
|
303
|
+
* console.log(view.elements); // the Spec elements of @xmachines/json-render-core
|
|
405
304
|
* }
|
|
406
305
|
* ```
|
|
407
306
|
*/
|
|
408
|
-
currentView;
|
|
307
|
+
currentView = new Signal.State(null);
|
|
409
308
|
constructor(machine, options, input, restoredSnapshot) {
|
|
410
|
-
//
|
|
309
|
+
// A defensive check before super(): a machine that is not an object fails deep
|
|
310
|
+
// inside the constructor of XState, with an opaque TypeError, and not with a coded
|
|
311
|
+
// error.
|
|
411
312
|
if (!machine || typeof machine !== "object") {
|
|
412
313
|
throw new InvalidMachineError();
|
|
413
314
|
}
|
|
414
|
-
//
|
|
415
|
-
//
|
|
416
|
-
//
|
|
417
|
-
// an
|
|
418
|
-
//
|
|
419
|
-
//
|
|
420
|
-
//
|
|
421
|
-
//
|
|
422
|
-
//
|
|
423
|
-
|
|
315
|
+
// THIS is the actor. The machine and its runtime options go directly to the Actor
|
|
316
|
+
// constructor of XState, which receives the same arguments as `createActor`
|
|
317
|
+
// forwards. Therefore no second instance answers for the real one, and every
|
|
318
|
+
// Actor member without an override here works on the real state.
|
|
319
|
+
//
|
|
320
|
+
// XState 5.28.0: the options object has a conditional type constraint on `input`,
|
|
321
|
+
// and TypeScript cannot resolve that constraint against an unbound generic
|
|
322
|
+
// TMachine. The cast stays inside the type system of XState, and the signature of
|
|
323
|
+
// this constructor gives `input` its type. Therefore each caller keeps the check
|
|
324
|
+
// at the compile time.
|
|
325
|
+
// Follow the improvements of the XState types: https://github.com/statelyai/xstate/issues
|
|
326
|
+
super(machine, {
|
|
424
327
|
input,
|
|
425
328
|
snapshot: restoredSnapshot,
|
|
329
|
+
inspect: options?.inspect,
|
|
426
330
|
});
|
|
427
|
-
//
|
|
428
|
-
|
|
429
|
-
//
|
|
430
|
-
//
|
|
431
|
-
//
|
|
432
|
-
// of
|
|
433
|
-
// initial
|
|
434
|
-
|
|
435
|
-
|
|
331
|
+
// Derive the initial route of the machine, for the detection of a restore or a
|
|
332
|
+
// deep link. The value is always the DEFAULT initial route of the machine, and
|
|
333
|
+
// never the route of the restored state. Without a snapshot of a restore, the
|
|
334
|
+
// pre-start snapshot of this actor IS that default initial state, and the code
|
|
335
|
+
// derives the route from it directly. A restore alone needs the pure
|
|
336
|
+
// `initialTransition` helper of XState. The inert actor scope of that helper runs
|
|
337
|
+
// the initial transition of the machine two more times, and one of them has an
|
|
338
|
+
// undefined `input`. This is a quirk of XState, and it costs too much for each
|
|
339
|
+
// other case.
|
|
340
|
+
this.initialRoute =
|
|
341
|
+
restoredSnapshot === undefined
|
|
342
|
+
? deriveCurrentRoute(this.getSnapshot())
|
|
343
|
+
: deriveInitialRoute(machine, input);
|
|
436
344
|
this.playerOptions = options || {};
|
|
437
|
-
// Initialize state signal.
|
|
438
|
-
// XState
|
|
439
|
-
//
|
|
440
|
-
//
|
|
441
|
-
this.state = new Signal.State(
|
|
442
|
-
// Initialize
|
|
443
|
-
this.viewSignal = new Signal.State(null);
|
|
444
|
-
// Initialize currentRoute computed signal
|
|
345
|
+
// Initialize the state signal. Each update is synchronous, with no batching in a
|
|
346
|
+
// microtask: XState groups the transitions of one send() call into one
|
|
347
|
+
// subscription callback already, and a synchronous update shows each guard
|
|
348
|
+
// redirect to a router bridge at once.
|
|
349
|
+
this.state = new Signal.State(this.getSnapshot());
|
|
350
|
+
// Initialize the currentRoute computed signal
|
|
445
351
|
this.currentRoute = new Signal.Computed(() => {
|
|
446
352
|
const snapshot = this.state.get();
|
|
447
353
|
return deriveCurrentRoute(snapshot);
|
|
448
354
|
});
|
|
449
|
-
//
|
|
450
|
-
|
|
451
|
-
//
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
355
|
+
// Observe the transitions of this actor. The code uses `super`, and not `this`, so
|
|
356
|
+
// that the bookkeeping of the next-only subscriptions in the subscribe override
|
|
357
|
+
// stays about the subscriptions of the user code.
|
|
358
|
+
super.subscribe({
|
|
359
|
+
next: (snapshot) => {
|
|
360
|
+
// Update on a stable state only: an active snapshot, and the "done" snapshot of a
|
|
361
|
+
// final state at the top level. The code skips an error snapshot and a stopped
|
|
362
|
+
// snapshot. Therefore a signal never freezes on an artifact of a teardown.
|
|
363
|
+
if (isObservableSnapshot(snapshot)) {
|
|
364
|
+
// Each state update is synchronous. Therefore a router bridge sees a guard redirect at once.
|
|
365
|
+
this.state.set(snapshot);
|
|
366
|
+
// Check the view and keep it after state and currentRoute hold their new values,
|
|
367
|
+
// and before the hooks.
|
|
368
|
+
// The order of the hooks is deliberate:
|
|
369
|
+
// 1. state and currentRoute receive their new values
|
|
370
|
+
// 2. currentView holds the new view
|
|
371
|
+
// 3. the onStateChange hook runs
|
|
372
|
+
// 4. send() then calls onTransition
|
|
373
|
+
this.validateAndCacheView(snapshot);
|
|
374
|
+
// Call the onStateChange hook
|
|
375
|
+
const onStateChange = this.hooks.onStateChange;
|
|
376
|
+
if (onStateChange) {
|
|
377
|
+
onStateChange(this, snapshot);
|
|
378
|
+
}
|
|
469
379
|
}
|
|
470
|
-
}
|
|
380
|
+
},
|
|
381
|
+
// The code registers the listener always, and it reads the handler at the moment
|
|
382
|
+
// of the DELIVERY: the options object is shared by its reference. Therefore an
|
|
383
|
+
// onError handler on that object after the construction still receives each actor
|
|
384
|
+
// error, and the removal of a handler stops the silence again.
|
|
385
|
+
// Without a handler, the listener throws the error again. The observer dispatch of
|
|
386
|
+
// XState then sends it to its global unhandled rethrow, which is the same loud
|
|
387
|
+
// default as a listener that the code registers not.
|
|
388
|
+
error: (error) => {
|
|
389
|
+
const handler = this.hooks.onError;
|
|
390
|
+
if (handler) {
|
|
391
|
+
handler(this, toError(error));
|
|
392
|
+
return;
|
|
393
|
+
}
|
|
394
|
+
// A next-only subscription of the user code makes XState report this delivery
|
|
395
|
+
// globally already. A second throw here reports it two times.
|
|
396
|
+
if ((this.nextOnlySubscriptions ?? 0) > 0) {
|
|
397
|
+
return;
|
|
398
|
+
}
|
|
399
|
+
throw error;
|
|
400
|
+
},
|
|
471
401
|
});
|
|
402
|
+
// The callbacks of the constructor of XState can reach everything above. After this
|
|
403
|
+
// point the instance is complete.
|
|
404
|
+
this.constructed = true;
|
|
472
405
|
}
|
|
473
406
|
/**
|
|
474
|
-
*
|
|
407
|
+
* Starts the actor.
|
|
475
408
|
*
|
|
476
|
-
*
|
|
409
|
+
* The method fires `onStart` on each real start, which is every transition from
|
|
410
|
+
* "not running" to "running". A start after a stop is such a transition, and XState
|
|
411
|
+
* permits it: its own `start()` stops only while the actor RUNS already. A second
|
|
412
|
+
* call while the actor runs fires no hook. Therefore a defensive double mount runs
|
|
413
|
+
* the side effects of `onStart` one time for one real start.
|
|
477
414
|
*/
|
|
478
415
|
start() {
|
|
479
|
-
|
|
480
|
-
//
|
|
481
|
-
if (this.
|
|
482
|
-
this
|
|
416
|
+
// See stop(): a call that reaches in through the construction window runs an actor
|
|
417
|
+
// that is not complete.
|
|
418
|
+
if (!this.constructed) {
|
|
419
|
+
return this;
|
|
420
|
+
}
|
|
421
|
+
super.start();
|
|
422
|
+
if (this.lifecycle !== "running") {
|
|
423
|
+
this.lifecycle = "running";
|
|
424
|
+
const onStart = this.hooks.onStart;
|
|
425
|
+
if (onStart) {
|
|
426
|
+
onStart(this);
|
|
427
|
+
}
|
|
483
428
|
}
|
|
484
429
|
return this;
|
|
485
430
|
}
|
|
486
431
|
/**
|
|
487
|
-
*
|
|
432
|
+
* Stops the actor and cleans up.
|
|
433
|
+
*
|
|
434
|
+
* The method fires `onStop` only when the actor ran. This matches XState, where a
|
|
435
|
+
* stop of an actor that never started, or of an actor that stopped already, does
|
|
436
|
+
* nothing and tears nothing down. Therefore the paired cleanup runs never two
|
|
437
|
+
* times, and it runs never against a resource that `onStart` did not take. A stop
|
|
438
|
+
* does not close the actor for ever: a later `start()` is a new lifecycle, and it
|
|
439
|
+
* fires `onStart` again.
|
|
488
440
|
*/
|
|
489
441
|
stop() {
|
|
490
|
-
|
|
491
|
-
//
|
|
492
|
-
|
|
493
|
-
|
|
442
|
+
// A call that reaches in through the construction window, for example a context
|
|
443
|
+
// factory that stops its own `self`, marks the actor as stopped before the code
|
|
444
|
+
// registers its internal subscription. XState drops each observer of a stopped
|
|
445
|
+
// actor. The signals therefore move never again. There is nothing to tear down
|
|
446
|
+
// during the construction, and the code ignores such a call.
|
|
447
|
+
if (!this.constructed) {
|
|
448
|
+
return this;
|
|
449
|
+
}
|
|
450
|
+
super.stop();
|
|
451
|
+
const wasRunning = this.lifecycle === "running";
|
|
452
|
+
this.lifecycle = "stopped";
|
|
453
|
+
const onStop = this.hooks.onStop;
|
|
454
|
+
if (wasRunning && onStop) {
|
|
455
|
+
onStop(this);
|
|
494
456
|
}
|
|
495
457
|
return this;
|
|
496
458
|
}
|
|
497
459
|
/**
|
|
498
|
-
*
|
|
460
|
+
* Sends an event to this actor.
|
|
499
461
|
*
|
|
500
|
-
* The
|
|
501
|
-
*
|
|
462
|
+
* The guards of the state machine of the actor decide if the event causes a
|
|
463
|
+
* transition. Give any event of the event union of the machine: a domain event, a
|
|
464
|
+
* routing event, and so on.
|
|
502
465
|
*
|
|
503
|
-
* @param event - An event
|
|
466
|
+
* @param event - An event of the `EventFromLogic<TMachine>` union of the machine.
|
|
504
467
|
*
|
|
505
|
-
* @throws {InvalidEventError} When `event` is not a plain object
|
|
506
|
-
* a string, number
|
|
468
|
+
* @throws {InvalidEventError} When `event` is not a plain object, for example
|
|
469
|
+
* `null`, `undefined`, a string, or a number. Import the class from
|
|
470
|
+
* `@xmachines/play-xstate/errors`.
|
|
507
471
|
*
|
|
508
472
|
* @example
|
|
509
473
|
* ```typescript
|
|
510
|
-
* //
|
|
474
|
+
* // A domain event, with the type of the event union of the machine
|
|
511
475
|
* actor.send({ type: "auth.login", userId: "123" });
|
|
512
476
|
*
|
|
513
|
-
* //
|
|
477
|
+
* // A routing event
|
|
514
478
|
* actor.send({ type: "play.route", to: "#home" });
|
|
515
479
|
* ```
|
|
516
480
|
*/
|
|
517
481
|
send(event) {
|
|
518
|
-
//
|
|
482
|
+
// A defensive check: the event must not be null and not undefined
|
|
519
483
|
if (!event || typeof event !== "object") {
|
|
520
484
|
throw new InvalidEventError(event);
|
|
521
485
|
}
|
|
522
|
-
//
|
|
523
|
-
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
|
|
529
|
-
|
|
530
|
-
|
|
486
|
+
// Inside the construction window there is no readable snapshot, and no hook can
|
|
487
|
+
// exist yet: deliver the event and return.
|
|
488
|
+
if (!this.constructed) {
|
|
489
|
+
Actor.prototype.send.call(this, event);
|
|
490
|
+
return;
|
|
491
|
+
}
|
|
492
|
+
// The code captures the snapshot always, because getSnapshot() is one property
|
|
493
|
+
// read, and because the options object is live: an onTransition handler that
|
|
494
|
+
// arrives during the processing of this event, for example from onStateChange,
|
|
495
|
+
// must still run for it, with the correct snapshot from before the send.
|
|
496
|
+
const prevSnapshot = this.getSnapshot();
|
|
497
|
+
// Send the event to the XState actor.
|
|
498
|
+
// `AbstractActor` declares send() as abstract for one reason only, to narrow the
|
|
499
|
+
// event type, and TypeScript forbids a super call to an abstract member.
|
|
500
|
+
// Therefore the code reaches the implementation of XState directly. `this` IS the
|
|
501
|
+
// actor. This call is therefore exactly the call of `super.send(event)`: the relay
|
|
502
|
+
// that emits the @xstate.event inspection event and puts the event in the mailbox
|
|
503
|
+
// of this actor.
|
|
504
|
+
Actor.prototype.send.call(this, event);
|
|
505
|
+
// Call the onTransition hook
|
|
506
|
+
const onTransition = this.hooks.onTransition;
|
|
507
|
+
if (onTransition) {
|
|
508
|
+
const nextSnapshot = this.getSnapshot();
|
|
509
|
+
onTransition(this, prevSnapshot, nextSnapshot);
|
|
531
510
|
}
|
|
532
511
|
}
|
|
533
512
|
/**
|
|
534
|
-
*
|
|
513
|
+
* Returns the current snapshot
|
|
535
514
|
*/
|
|
536
515
|
getSnapshot() {
|
|
537
|
-
return
|
|
516
|
+
return super.getSnapshot();
|
|
538
517
|
}
|
|
539
518
|
subscribe(nextListenerOrObserver, errorListener, completeListener) {
|
|
540
|
-
//
|
|
541
|
-
//
|
|
542
|
-
|
|
519
|
+
// The subscribe() method of XState accepts a function and also an observer, and it
|
|
520
|
+
// normalizes them internally. The cast joins the two overload signatures only.
|
|
521
|
+
const subscription = super.subscribe(nextListenerOrObserver, errorListener, completeListener);
|
|
522
|
+
// An observer without an error listener makes XState throw each actor error again,
|
|
523
|
+
// globally, for that observer. The code counts those observers. Therefore the loud
|
|
524
|
+
// default of the internal listener, which also throws again, stands down while one
|
|
525
|
+
// of them is active. See the error listener of the constructor.
|
|
526
|
+
const hasErrorListener = typeof nextListenerOrObserver === "object" && nextListenerOrObserver !== null
|
|
527
|
+
? typeof nextListenerOrObserver.error === "function"
|
|
528
|
+
: typeof errorListener === "function";
|
|
529
|
+
if (hasErrorListener) {
|
|
530
|
+
return subscription;
|
|
531
|
+
}
|
|
532
|
+
this.nextOnlySubscriptions = (this.nextOnlySubscriptions ?? 0) + 1;
|
|
533
|
+
let counted = true;
|
|
534
|
+
return {
|
|
535
|
+
unsubscribe: () => {
|
|
536
|
+
if (counted) {
|
|
537
|
+
counted = false;
|
|
538
|
+
this.nextOnlySubscriptions = (this.nextOnlySubscriptions ?? 1) - 1;
|
|
539
|
+
}
|
|
540
|
+
subscription.unsubscribe();
|
|
541
|
+
},
|
|
542
|
+
};
|
|
543
543
|
}
|
|
544
544
|
/**
|
|
545
|
-
*
|
|
545
|
+
* Listens for the events that this actor emits with the `emit` action.
|
|
546
546
|
*
|
|
547
|
-
* @param type -
|
|
548
|
-
* @param handler -
|
|
549
|
-
* @returns
|
|
547
|
+
* @param type - The type of the emitted event to listen for, or `"*"` for every event.
|
|
548
|
+
* @param handler - The actor calls it with each emitted event that matches.
|
|
549
|
+
* @returns The subscription, with an `unsubscribe()` method.
|
|
550
550
|
*/
|
|
551
551
|
on(type, handler) {
|
|
552
|
-
return
|
|
552
|
+
return super.on(type, handler);
|
|
553
553
|
}
|
|
554
554
|
/**
|
|
555
|
-
*
|
|
555
|
+
* Returns the persisted snapshot of this actor.
|
|
556
556
|
*
|
|
557
|
-
*
|
|
558
|
-
* `restore.snapshot` option.
|
|
557
|
+
* Use it to serialize the state, and to restore it later with the
|
|
558
|
+
* `restore.snapshot` option of the factory.
|
|
559
559
|
*/
|
|
560
|
-
getPersistedSnapshot() {
|
|
561
|
-
|
|
560
|
+
getPersistedSnapshot(options) {
|
|
561
|
+
const forward = super.getPersistedSnapshot;
|
|
562
|
+
return forward.call(this, options);
|
|
562
563
|
}
|
|
563
564
|
/**
|
|
564
|
-
*
|
|
565
|
+
* Derives the view at the entry of a state, and keeps it. This happens one time for
|
|
566
|
+
* each transition. The signal holds the view, and the code computes it not on each
|
|
567
|
+
* read.
|
|
565
568
|
*
|
|
566
|
-
*
|
|
567
|
-
* Validates once per transition and stores result in signal.
|
|
568
|
-
*
|
|
569
|
-
* @param snapshot - Current XState snapshot
|
|
569
|
+
* @param snapshot - The current XState snapshot
|
|
570
570
|
*/
|
|
571
571
|
validateAndCacheView(snapshot) {
|
|
572
|
+
if (snapshot === this.lastViewSnapshot) {
|
|
573
|
+
return;
|
|
574
|
+
}
|
|
575
|
+
this.lastViewSnapshot = snapshot;
|
|
572
576
|
try {
|
|
573
577
|
const view = deriveCurrentView(snapshot);
|
|
574
|
-
// Emit only
|
|
575
|
-
// returns a fresh object on
|
|
576
|
-
//
|
|
577
|
-
//
|
|
578
|
-
//
|
|
579
|
-
//
|
|
580
|
-
|
|
578
|
+
// Emit only after a real change of the view on the screen: deriveCurrentView
|
|
579
|
+
// returns a fresh object on each call, and the identity of the reference therefore
|
|
580
|
+
// tells nothing. A new reference for a snapshot that changes the view not, for
|
|
581
|
+
// example a context-only assign, makes a provider below mount the UI again, and
|
|
582
|
+
// that removes the state of the view. A deep equality test is deliberately NOT
|
|
583
|
+
// here: it sees nothing inside a Map or a Set, and it therefore stops a real
|
|
584
|
+
// change, and it recurses without an end on a cyclic prop. The last spec of an
|
|
585
|
+
// emission IS the current value of the signal. Read it without a track, so that
|
|
586
|
+
// the gate registers currentView never as a dependency of a computation around
|
|
587
|
+
// it.
|
|
588
|
+
const lastEmittedView = Signal.subtle.untrack(() => this.currentView.get());
|
|
589
|
+
// Use the reference of the previous composed state again when the value of the
|
|
590
|
+
// /context projection did not change. A context-only assign that changes no
|
|
591
|
+
// projected value therefore changes the identity of the state not.
|
|
592
|
+
const nextView = reuseComposedState(lastEmittedView, view);
|
|
593
|
+
if (viewSpecsEquivalent(lastEmittedView, nextView)) {
|
|
581
594
|
return;
|
|
582
595
|
}
|
|
583
|
-
this.
|
|
584
|
-
this.viewSignal.set(view);
|
|
596
|
+
this.currentView.set(nextView);
|
|
585
597
|
}
|
|
586
598
|
catch (error) {
|
|
587
|
-
|
|
588
|
-
|
|
589
|
-
|
|
599
|
+
const onError = this.hooks.onError;
|
|
600
|
+
if (onError) {
|
|
601
|
+
onError(this, toError(error));
|
|
590
602
|
}
|
|
591
|
-
// On error: keep the last valid view
|
|
603
|
+
// On an error: keep the last valid view, and clear it not
|
|
592
604
|
}
|
|
593
605
|
}
|
|
594
606
|
/**
|
|
595
|
-
*
|
|
607
|
+
* The dispose method, for the cleanup. It is the alias of {@link stop}.
|
|
596
608
|
*
|
|
597
|
-
*
|
|
609
|
+
* @deprecated Use {@link stop}. Will be removed in the next major.
|
|
598
610
|
*/
|
|
599
611
|
dispose() {
|
|
600
612
|
this.stop();
|