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