@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.
Files changed (89) hide show
  1. package/README.md +114 -114
  2. package/dist/define-player.d.ts +16 -16
  3. package/dist/define-player.js +16 -16
  4. package/dist/errors.d.ts +84 -101
  5. package/dist/errors.d.ts.map +1 -1
  6. package/dist/errors.js +108 -108
  7. package/dist/errors.js.map +1 -1
  8. package/dist/guards/compose.d.ts +70 -77
  9. package/dist/guards/compose.d.ts.map +1 -1
  10. package/dist/guards/compose.js +90 -113
  11. package/dist/guards/compose.js.map +1 -1
  12. package/dist/guards/helpers.d.ts +22 -18
  13. package/dist/guards/helpers.d.ts.map +1 -1
  14. package/dist/guards/helpers.js +23 -19
  15. package/dist/guards/helpers.js.map +1 -1
  16. package/dist/guards/index.d.ts +10 -3
  17. package/dist/guards/index.d.ts.map +1 -1
  18. package/dist/guards/index.js +10 -3
  19. package/dist/guards/index.js.map +1 -1
  20. package/dist/guards/types.d.ts +9 -9
  21. package/dist/guards/types.d.ts.map +1 -1
  22. package/dist/index.d.ts +8 -8
  23. package/dist/index.d.ts.map +1 -1
  24. package/dist/index.js +9 -10
  25. package/dist/index.js.map +1 -1
  26. package/dist/player-actor.d.ts +197 -113
  27. package/dist/player-actor.d.ts.map +1 -1
  28. package/dist/player-actor.js +413 -401
  29. package/dist/player-actor.js.map +1 -1
  30. package/dist/routing/build-url.d.ts +19 -21
  31. package/dist/routing/build-url.d.ts.map +1 -1
  32. package/dist/routing/build-url.js +70 -71
  33. package/dist/routing/build-url.js.map +1 -1
  34. package/dist/routing/derive-current-route.d.ts +51 -13
  35. package/dist/routing/derive-current-route.d.ts.map +1 -1
  36. package/dist/routing/derive-current-route.js +69 -60
  37. package/dist/routing/derive-current-route.js.map +1 -1
  38. package/dist/routing/derive-initial-route.d.ts +23 -23
  39. package/dist/routing/derive-initial-route.js +27 -27
  40. package/dist/routing/derive-initial-route.js.map +1 -1
  41. package/dist/routing/derive-route.d.ts +38 -37
  42. package/dist/routing/derive-route.d.ts.map +1 -1
  43. package/dist/routing/derive-route.js +45 -42
  44. package/dist/routing/derive-route.js.map +1 -1
  45. package/dist/routing/format-play-route-transitions.d.ts +34 -71
  46. package/dist/routing/format-play-route-transitions.d.ts.map +1 -1
  47. package/dist/routing/format-play-route-transitions.js +74 -130
  48. package/dist/routing/format-play-route-transitions.js.map +1 -1
  49. package/dist/routing/index.d.ts +4 -8
  50. package/dist/routing/index.d.ts.map +1 -1
  51. package/dist/routing/index.js +4 -6
  52. package/dist/routing/index.js.map +1 -1
  53. package/dist/routing/types.d.ts +12 -11
  54. package/dist/routing/types.d.ts.map +1 -1
  55. package/dist/types.d.ts +97 -20
  56. package/dist/types.d.ts.map +1 -1
  57. package/dist/view/derive-current-view.d.ts +51 -0
  58. package/dist/view/derive-current-view.d.ts.map +1 -0
  59. package/dist/view/derive-current-view.js +119 -0
  60. package/dist/view/derive-current-view.js.map +1 -0
  61. package/package.json +22 -21
  62. package/dist/define-player.typecheck.d.ts +0 -2
  63. package/dist/define-player.typecheck.d.ts.map +0 -1
  64. package/dist/define-player.typecheck.js +0 -48
  65. package/dist/define-player.typecheck.js.map +0 -1
  66. package/dist/guards/compose.typecheck.d.ts +0 -2
  67. package/dist/guards/compose.typecheck.d.ts.map +0 -1
  68. package/dist/guards/compose.typecheck.js +0 -22
  69. package/dist/guards/compose.typecheck.js.map +0 -1
  70. package/dist/player-actor.typecheck.d.ts +0 -2
  71. package/dist/player-actor.typecheck.d.ts.map +0 -1
  72. package/dist/player-actor.typecheck.js +0 -30
  73. package/dist/player-actor.typecheck.js.map +0 -1
  74. package/dist/routing/create-routed-machine.d.ts +0 -71
  75. package/dist/routing/create-routed-machine.d.ts.map +0 -1
  76. package/dist/routing/create-routed-machine.js +0 -71
  77. package/dist/routing/create-routed-machine.js.map +0 -1
  78. package/dist/routing/play-route-event.typecheck.d.ts +0 -2
  79. package/dist/routing/play-route-event.typecheck.d.ts.map +0 -1
  80. package/dist/routing/play-route-event.typecheck.js +0 -43
  81. package/dist/routing/play-route-event.typecheck.js.map +0 -1
  82. package/dist/routing/schemas.d.ts +0 -99
  83. package/dist/routing/schemas.d.ts.map +0 -1
  84. package/dist/routing/schemas.js +0 -30
  85. package/dist/routing/schemas.js.map +0 -1
  86. package/dist/schemas.d.ts +0 -28
  87. package/dist/schemas.d.ts.map +0 -1
  88. package/dist/schemas.js +0 -29
  89. package/dist/schemas.js.map +0 -1
@@ -1,125 +1,73 @@
1
- import { createActor, } from "xstate";
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
- const hasSnapshotStatus = (snapshot) => {
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
- * A snapshot worth propagating to signals: an active snapshot or a "done"
17
- * snapshot (top-level final state reached). Error and stopped snapshots are
18
- * skipped so signals keep the last observable state instead of surfacing
19
- * teardown/error artifacts.
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 isObservableSnapshot = (snapshot) => {
22
- return (hasSnapshotStatus(snapshot) && (snapshot.status === "active" || snapshot.status === "done"));
23
- };
24
- const resolveViewMeta = (meta) => {
25
- // Iterate last-to-first: snapshot.getMeta() orders keys ancestors-first, so
26
- // scanning from the end makes the deepest active state's meta.view win over
27
- // an ancestor's instead of being shadowed by it.
28
- const values = Object.values(meta);
29
- for (let i = values.length - 1; i >= 0; i--) {
30
- const stateMeta = values[i]; // nosemgrep: gitlab.eslint.detect-object-injection
31
- const maybeView = stateMeta && typeof stateMeta === "object"
32
- ? stateMeta.view
33
- : undefined;
34
- if (maybeView &&
35
- typeof maybeView === "object" &&
36
- "root" in maybeView &&
37
- "elements" in maybeView) {
38
- return maybeView;
39
- }
40
- }
41
- return null;
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
- * Merge route parameters and an explicit allowlist of context fields into a single
45
- * element's props.
46
- *
47
- * Priority rule (highest → lowest):
48
- * 1. Explicit non-`undefined` spec prop — always wins (static values are authoritative).
49
- * 2. URL route params (`context.params`) — fills `undefined` slots from the URL path.
50
- * 3. Allowlisted context fields (`contextProps`) — fills remaining `undefined` slots from
51
- * the machine context (e.g. `context.username` on a state with no URL username param).
52
- *
53
- * The `contextProps` allowlist is the key safety mechanism: only fields the machine
54
- * explicitly opts in to are ever exposed to components.
55
- *
56
- * @param urlParams - Extracted URL path parameters from `context.params`.
57
- * @param contextValues - Object containing only the allowlisted context fields.
58
- * @param existingProps - The element's props as declared in the machine spec.
59
- * @returns Merged props object with URL params and allowlisted context values filling
60
- * undefined slots.
25
+ * Normalizes a failure of the actor for `onError`.
61
26
  *
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
- * ```
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
- function mergeRouteParamsIntoProps(urlParams, contextValues, existingProps) {
71
- // Layer 3 (lowest priority): allowlisted context values
72
- const merged = { ...contextValues };
73
- // Layer 2: URL params override context values for the same key
74
- for (const [k, v] of Object.entries(urlParams)) {
75
- merged[k] = v; // nosemgrep: gitlab.eslint.detect-object-injection
33
+ const toError = (value) => {
34
+ try {
35
+ if (isRealError(value)) {
36
+ return value;
37
+ }
76
38
  }
77
- // Layer 1 (highest priority): explicit non-undefined spec props always win
78
- for (const [k, v] of Object.entries(existingProps)) {
79
- if (v !== undefined)
80
- merged[k] = v; // nosemgrep: gitlab.eslint.detect-object-injection
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 merged;
83
- }
44
+ return new ActorThrewNonErrorError(value);
45
+ };
84
46
  /**
85
- * Compare two derived `PlaySpec` objects for rendered-view equivalence.
86
- *
87
- * `deriveCurrentView` returns a fresh object on every call, so reference equality
88
- * cannot tell whether the view actually changed. This helper performs a cheap
89
- * structural comparison over the inputs that determine the rendered output:
47
+ * The structural equality of two derived view specs, with a limit on its depth.
90
48
  *
91
- * - Top-level spec fields (`root`, `contextProps`, …) by identity — they are
92
- * primitives or references copied unchanged from the machine's stable `meta.view`.
93
- * - Per-element fields other than `props` by identity — same reasoning.
94
- * - Element `props` by shallow `Object.is` per key — this is where URL params and
95
- * allowlisted context values are merged in, so a param/context change that alters
96
- * the rendered spec is detected here.
97
- *
98
- * Used by `PlayerActor.validateAndCacheView` to skip re-emitting the view signal
99
- * for snapshots that do not change the rendered view (e.g. context-only assigns).
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 areViewSpecsEquivalent = (a, b) => {
57
+ const viewSpecsEquivalent = (a, b) => {
102
58
  if (a === b)
103
59
  return true;
104
- if (a === null || b === null)
60
+ if (!a || !b)
105
61
  return false;
106
- const aRecord = a;
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
- for (const key of topKeys) {
112
- if (key === "elements")
113
- continue;
114
- if (!Object.is(aRecord[key], bRecord[key]))
115
- return false; // nosemgrep: gitlab.eslint.detect-object-injection
116
- }
117
- const aElements = (aRecord.elements ?? null);
118
- const bElements = (bRecord.elements ?? null);
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
- const fieldKeys = Object.keys(aElement);
134
- if (fieldKeys.length !== Object.keys(bElement).length)
81
+ if (!shallowEqualExcept(aElement, bElement, "props"))
135
82
  return false;
136
- for (const field of fieldKeys) {
137
- if (field === "props")
138
- continue;
139
- if (!Object.is(aElement[field], bElement[field]))
140
- return false; // nosemgrep: gitlab.eslint.detect-object-injection
141
- }
142
- const aProps = (aElement.props ?? {});
143
- const bProps = (bElement.props ?? {});
144
- const propKeys = Object.keys(aProps);
145
- if (propKeys.length !== Object.keys(bProps).length)
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
- * Derive the actor's current view from state metadata.
156
- *
157
- * Always returns a **fresh** `PlaySpec` object so a genuine view change is never
158
- * suppressed by `Signal.State`'s `Object.is` equality check. Whether that fresh
159
- * object is actually emitted is decided by `PlayerActor.validateAndCacheView`,
160
- * which compares against the last emitted spec (see {@link areViewSpecsEquivalent})
161
- * and skips emission when the rendered view is unchanged — preventing downstream
162
- * providers from remounting the UI on every event.
163
- *
164
- * ### Prop enrichment
165
- *
166
- * Each spec element's `props` are enriched before the view is emitted using two sources:
167
- *
168
- * **1. URL route params** (`context.params`, populated by `formatPlayRouteTransitions`):
169
- * Makes `:section?` / `:username` URL path parameters automatically available as component
170
- * props without manual wiring.
171
- *
172
- * **2. Context fields** (`spec.contextProps` allowlist):
173
- * Exposes selected context fields as component props for states where no URL param covers
174
- * the data (e.g. `context.username` on a `/dashboard` route). Only fields explicitly named
175
- * in `spec.contextProps: string[]` are ever exposed — nothing leaks from context implicitly.
176
- *
177
- * Merge priority (see `mergeRouteParamsIntoProps`):
178
- * 1. Explicit non-`undefined` spec prop — always wins (authoritative static value).
179
- * 2. URL route param (`context.params`) — fills `undefined` slots from the URL path.
180
- * 3. Allowlisted context field (`contextProps`) — fills remaining `undefined` slots.
181
- *
182
- * @param snapshot - Current XState machine snapshot.
183
- * @returns Enriched `PlaySpec`, or `null` if the current state has no view metadata.
184
- *
185
- * @example
186
- * ```ts
187
- * // spec: { contextProps: ["username"], elements: { root: { props: { username: undefined } } } }
188
- * // context.username = "alice", context.params = {}
189
- * // Derived props: { username: "alice" }
190
- *
191
- * // spec: { contextProps: ["username"], elements: { root: { props: { username: undefined } } } }
192
- * // context.username = "alice", context.params = { username: "demo" }
193
- * // Derived props: { username: "demo" } ← URL param wins
194
- * ```
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 deriveCurrentView = (snapshot) => {
197
- if (!snapshot) {
198
- return null;
199
- }
200
- const meta = snapshot.getMeta();
201
- if (!meta || typeof meta !== "object") {
202
- return null;
203
- }
204
- const viewMeta = resolveViewMeta(meta);
205
- if (!viewMeta) {
206
- return null;
207
- }
208
- // Extract params from context (set by formatPlayRouteTransitions on play.route events)
209
- const context = snapshot.context !== null &&
210
- snapshot.context !== undefined &&
211
- typeof snapshot.context === "object"
212
- ? snapshot.context
213
- : null;
214
- const urlParams = context !== null && typeof context.params === "object" && context.params !== null
215
- ? context.params
216
- : {};
217
- // Extract the allowlisted context fields declared in contextProps.
218
- // Only fields explicitly named in the allowlist are ever exposed to components.
219
- const contextPropsAllowlist = viewMeta?.contextProps ?? [];
220
- const contextValues = {};
221
- if (context !== null && contextPropsAllowlist.length > 0) {
222
- for (const key of contextPropsAllowlist) {
223
- // nosemgrep: gitlab.eslint.detect-object-injection
224
- if (key in context && context[key] !== null && context[key] !== undefined) {
225
- contextValues[key] = context[key]; // nosemgrep: gitlab.eslint.detect-object-injection
226
- }
227
- }
228
- }
229
- // Enrich element props when there is anything to merge.
230
- if (viewMeta?.elements) {
231
- const enrichedElements = Object.fromEntries(Object.entries(viewMeta.elements).map(([key, el]) => {
232
- const element = el;
233
- const existingProps = element.props ?? {};
234
- return [
235
- key,
236
- {
237
- ...element,
238
- props: mergeRouteParamsIntoProps(urlParams, contextValues, existingProps),
239
- },
240
- ];
241
- }));
242
- return { ...viewMeta, elements: enrichedElements };
243
- }
244
- return viewMeta;
245
- };
95
+ const isObservableSnapshot = (snapshot) => snapshot.status === "active" || snapshot.status === "done";
246
96
  /**
247
- * Concrete XState actor implementing Play Architecture signal protocol
97
+ * The concrete XState actor. It implements the signal protocol of the Play Architecture
248
98
  *
249
- * Extends {@link @xmachines/play-actor!AbstractActor} to provide XState v6 integration
250
- * while maintaining ecosystem compatibility (XState inspection, devtools). This actor
251
- * wraps an internal XState actor and exposes TC39 Signal-based reactive state for
252
- * Infrastructure observation.
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:** Implements both {@link @xmachines/play-actor!Routable} and
255
- * {@link @xmachines/play-actor!Viewable} interfaces, providing routing and view
256
- * rendering support.
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 Context:** Implements **Actor Authority (INV-01)** by ensuring the
259
- * XState machine's guards control all navigation decisions. Infrastructure observes
260
- * the actor's signals (`state`, `currentRoute`, `currentView`) but cannot directly
261
- * manipulate state—all mutations flow through the state machine's event handlers.
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 v6 state machine type
118
+ * @typeParam TMachine - The type of the XState v5 state machine
264
119
  *
265
120
  * @example
266
- * Basic actor creation and lifecycle
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: { route: '/', view: { component: 'HomePage' } }
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()); // { component: 'HomePage' }
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
- * Signal lifecycle with watchers
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
- * // Watcher notification scheduled via microtask by the watcher itself
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 factory creation
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
- * **Routing:** This actor supports both XState's `route: {}` config pattern
314
- * and `play.route` events with parameters. The `deriveRoute()` function checks
315
- * `meta.route` (Stately pattern) for URL templates with parameter substitution support.
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
- * **View Signal Pattern:** The `currentView` signal is a direct `Signal.State` (not
318
- * `Signal.Computed`) to ensure proper watcher propagation in PlayRenderer. Views are
319
- * cached and updated at state entry, not computed on every read.
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
- viewSignal;
325
- /** Last spec emitted on the view signal — used to skip no-change re-emissions. */
326
- lastEmittedView = null;
327
- // 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.
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
- * Returns whether the actor's current state can accept the given event.
218
+ * Tells you if the current state of the actor accepts the given event.
333
219
  *
334
- * Typed to the machine's event union — passing an unknown event type is a
335
- * compile error. Delegates to the underlying XState actor's snapshot.
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
- // SnapshotFrom<TMachine> does not narrow to MachineSnapshot for an unbound
344
- // generic TMachine, so widen to AnyMachineSnapshot for the .can() call. The
345
- // public signature stays typed to the machine's event union.
346
- return this.state.get().can(event);
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` that derives the current URL path from the active
350
- * machine state's `meta.route` template and the actor's context.
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
- * Returns `null` when the current state has no `meta.route`, or when the route
353
- * template cannot be fully resolved (e.g. a required parameter is absent from
354
- * context).
355
- *
356
- * @throws {MissingRouteParamError} When a required `:param` placeholder in the
357
- * route template has no matching value in the actor's context. Import the class
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
- * // Returns "/profile/alice" when context.userId === "alice"
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 derived from the machine's initial state — fixed at construction,
369
- * never changes even when the actor is restored from a snapshot.
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
- * Router bridges compare this against the browser URL to distinguish a deep-link
372
- * (non-initial URL → router wins) from a restore (initial URL + actor at a
373
- * different restored route → actor wins).
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
- * Derived statically from the machine definition via `deriveInitialRoute`
376
- * (XState's pure `initialTransition` helper): the initial state chain and its
377
- * `meta.route` templates are fixed at machine definition time, while `:param`
378
- * substitution uses the machine's real initial context for this actor's `input`.
379
- * No extra actor is ever created, and a restored snapshot never influences the
380
- * value — it is always the machine's **default** initial route.
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
- * Reactive signal containing the current view spec derived from the active state's
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
- * Emits a **fresh object reference** whenever the rendered view actually changes —
388
- * a different state's view, or a param/context change that alters the resolved
389
- * spec (including `reenter: true` re-entries with new params). Snapshots that do
390
- * not change the rendered view (e.g. context-only assigns) keep the previous
391
- * reference so downstream providers do not remount the UI on every event.
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 emitted `PlaySpec` has its element `props` enriched with `context.params`
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
- * Returns `null` when the current state has no `meta.view` metadata.
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); // e.g. "root"
404
- * console.log(view.elements); // @xmachines/json-render-core Spec elements
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
- // Defensive check: Validate machine before passing to createActor
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
- // Create XState actor
415
- // XState v6: createActor() intersects ActorOptions with a mapped type over
416
- // RequiredActorOptionsKeys<TLogic>, which TypeScript cannot resolve against
417
- // an unbound generic TMachine — and under exactOptionalPropertyTypes an
418
- // `input: InputFrom<TMachine> | undefined` property does not satisfy it.
419
- // Cast to ActorOptions<TMachine> — narrower than `as any` because we remain
420
- // within the XState type system. The `input` parameter is typed as
421
- // InputFrom<TMachine> at the constructor call site, so consumers receive
422
- // compile-time validation.
423
- const xstateActor = createActor(machine, {
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
- // Call AbstractActor constructor with actor logic
428
- super(xstateActor.logic);
429
- // Derive the machine's initial route for restore-vs-deeplink detection.
430
- // Statically computed from the machine definition + this actor's input via
431
- // XState's pure `initialTransition` helper — no extra actor, and independent
432
- // of any restored snapshot, so bridges always see the machine's DEFAULT
433
- // initial route rather than the restored state's.
434
- this.initialRoute = deriveInitialRoute(machine, input);
435
- this.xstateActor = xstateActor;
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. Updates are synchronous (no microtask batching):
438
- // XState already coalesces multiple transitions within a single send() into
439
- // one subscription callback, and synchronous updates ensure guard-triggered
440
- // redirects are immediately visible to router bridges.
441
- this.state = new Signal.State(xstateActor.getSnapshot());
442
- // Initialize view signal
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
- // Expose view signal directly for proper watcher propagation
450
- this.currentView = this.viewSignal;
451
- // Subscribe to XState actor transitions
452
- this.xstateActor.subscribe((snapshot) => {
453
- // Only update on stable states per CONTEXT.md: active snapshots and the
454
- // "done" snapshot from a top-level final state. Error/stopped snapshots
455
- // are skipped so signals never freeze on teardown artifacts.
456
- if (isObservableSnapshot(snapshot)) {
457
- // State updates are synchronous so router bridges see guard redirects immediately.
458
- this.state.set(snapshot);
459
- // Validate and cache the view after state/currentRoute are current, before hooks.
460
- // Hook ordering is intentional:
461
- // 1. state/currentRoute updated
462
- // 2. currentView cached
463
- // 3. onStateChange hook
464
- // 4. send() then invokes onTransition
465
- this.validateAndCacheView(snapshot);
466
- // Call onStateChange hook
467
- if (this.playerOptions.onStateChange) {
468
- this.playerOptions.onStateChange(this, snapshot);
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
- * Start the actor
407
+ * Starts the actor.
475
408
  *
476
- * Per RESEARCH.md Pitfall 1: Always call start() after creation
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
- this.xstateActor.start();
480
- // Call onStart hook
481
- if (this.playerOptions.onStart) {
482
- this.playerOptions.onStart(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
- * Stop the actor and cleanup
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
- this.xstateActor.stop();
491
- // Call onStop hook
492
- if (this.playerOptions.onStop) {
493
- this.playerOptions.onStop(this);
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
- * Send an event to the underlying XState actor.
460
+ * Sends an event to this actor.
499
461
  *
500
- * The actor's state machine guards decide whether the event causes a transition.
501
- * Pass any event from the machine's event union — domain events, routing events, etc.
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 from the machine's `EventFromLogic<TMachine>` union.
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 (`null`, `undefined`,
506
- * a string, number, etc.). Import the class from `@xmachines/play-xstate/errors`.
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
- * // Domain event (typed to machine's event union)
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
- * // Routing event
477
+ * // A routing event
514
478
  * actor.send({ type: "play.route", to: "#home" });
515
479
  * ```
516
480
  */
517
481
  send(event) {
518
- // Defensive check: Validate event is not null/undefined
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
- // Store previous state for onTransition hook
523
- const prevSnapshot = this.xstateActor.getSnapshot();
524
- // Send to XState actor
525
- // EventFromLogic<TMachine> is exactly what xstateActor.send expects — no cast needed.
526
- this.xstateActor.send(event);
527
- // Call onTransition hook
528
- if (this.playerOptions.onTransition) {
529
- const nextSnapshot = this.xstateActor.getSnapshot();
530
- this.playerOptions.onTransition(this, prevSnapshot, nextSnapshot);
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
- * Get current snapshot
513
+ * Returns the current snapshot
535
514
  */
536
515
  getSnapshot() {
537
- return this.xstateActor.getSnapshot();
516
+ return super.getSnapshot();
538
517
  }
539
518
  subscribe(nextListenerOrObserver, errorListener, completeListener) {
540
- // XState's subscribe() normalizes function-or-observer internally; the cast
541
- // only reconciles the overload signatures.
542
- return this.xstateActor.subscribe(nextListenerOrObserver, errorListener, completeListener);
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
- * Listen for events emitted by the wrapped XState actor via the `emit` action.
545
+ * Listens for the events that this actor emits with the `emit` action.
546
546
  *
547
- * @param type - Emitted event type to listen for, or `"*"` for all.
548
- * @param handler - Called with each matching emitted event.
549
- * @returns Subscription with an `unsubscribe()` method.
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 this.xstateActor.on(type, handler);
552
+ return super.on(type, handler);
553
553
  }
554
554
  /**
555
- * Get the persisted snapshot of the wrapped XState actor.
555
+ * Returns the persisted snapshot of this actor.
556
556
  *
557
- * Suitable for serialization and later restoration via the factory's
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
- return this.xstateActor.getPersistedSnapshot();
560
+ getPersistedSnapshot(options) {
561
+ const forward = super.getPersistedSnapshot;
562
+ return forward.call(this, options);
562
563
  }
563
564
  /**
564
- * Validate view at state entry and cache result
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
- * Per CONTEXT.md: "Prop validation: At state entry (when state becomes active)"
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 when the rendered view actually changed. deriveCurrentView
575
- // returns a fresh object on every call (so genuine changes always pass
576
- // Signal.State's Object.is gate), but re-emitting a fresh reference for
577
- // snapshots that do not alter the view (e.g. context-only assigns) would
578
- // make downstream providers treat every event as a view transition and
579
- // remount the UI, wiping in-view state.
580
- if (areViewSpecsEquivalent(this.lastEmittedView, view)) {
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.lastEmittedView = view;
584
- this.viewSignal.set(view);
596
+ this.currentView.set(nextView);
585
597
  }
586
598
  catch (error) {
587
- if (this.playerOptions.onError) {
588
- const err = error instanceof Error ? error : new Error(String(error));
589
- this.playerOptions.onError(this, err);
599
+ const onError = this.hooks.onError;
600
+ if (onError) {
601
+ onError(this, toError(error));
590
602
  }
591
- // On error: keep the last valid view (don't clear)
603
+ // On an error: keep the last valid view, and clear it not
592
604
  }
593
605
  }
594
606
  /**
595
- * Convenience dispose method for cleanup
607
+ * The dispose method, for the cleanup. It is the alias of {@link stop}.
596
608
  *
597
- * Per CONTEXT.md: "Both .dispose() convenience method and manual machine.stop()"
609
+ * @deprecated Use {@link stop}. Will be removed in the next major.
598
610
  */
599
611
  dispose() {
600
612
  this.stop();